Micron Document
πŸŽ–οΈGitΠ―Ρ€Π°πŸŽ–οΈ

Node / meshtastic / Meshtastic-Android / files / core / network / src / commonTest / kotlin / org / meshtastic / core / network / radio / BleRadioTransportReconnectCrashTest.kt

Displaying Raw β€’ Download

core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportReconnectCrashTest.kt 65a1f5ce4d0e4b8eafcd0bbfccafd41dc6ee888a (65a1f5ce) Text, 58.83 KB

T8b949e/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
Tff7b72package T7ee787org.meshtastic.core.network.radio

Tff7b72import T7ee787com.juul.kable.GattStatusException
Tff7b72import T7ee787com.juul.kable.NotConnectedException
Tff7b72import T7ee787dev.mokkery.MockMode
Tff7b72import T7ee787dev.mokkery.answering.calls
Tff7b72import T7ee787dev.mokkery.answering.returns
Tff7b72import T7ee787dev.mokkery.every
Tff7b72import T7ee787dev.mokkery.matcher.any
Tff7b72import T7ee787dev.mokkery.mock
Tff7b72import T7ee787kotlinx.coroutines.CancellationException
Tff7b72import T7ee787kotlinx.coroutines.CoroutineScope
Tff7b72import T7ee787kotlinx.coroutines.ExperimentalCoroutinesApi
Tff7b72import T7ee787kotlinx.coroutines.awaitCancellation
Tff7b72import T7ee787kotlinx.coroutines.currentCoroutineContext
Tff7b72import T7ee787kotlinx.coroutines.flow.Flow
Tff7b72import T7ee787kotlinx.coroutines.flow.MutableStateFlow
Tff7b72import T7ee787kotlinx.coroutines.flow.StateFlow
Tff7b72import T7ee787kotlinx.coroutines.flow.asStateFlow
Tff7b72import T7ee787kotlinx.coroutines.flow.flow
Tff7b72import T7ee787kotlinx.coroutines.test.advanceTimeBy
Tff7b72import T7ee787kotlinx.coroutines.test.runTest
Tff7b72import T7ee787org.meshtastic.core.ble.BleConnection
Tff7b72import T7ee787org.meshtastic.core.ble.BleConnectionFactory
Tff7b72import T7ee787org.meshtastic.core.ble.BleConnectionState
Tff7b72import T7ee787org.meshtastic.core.ble.BleDevice
Tff7b72import T7ee787org.meshtastic.core.ble.BleScanner
Tff7b72import T7ee787org.meshtastic.core.ble.BleService
Tff7b72import T7ee787org.meshtastic.core.ble.BleWriteType
Tff7b72import T7ee787org.meshtastic.core.ble.DisconnectReason
Tff7b72import T7ee787org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC
Tff7b72import T7ee787org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
Tff7b72import T7ee787org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
Tff7b72import T7ee787org.meshtastic.core.testing.FakeBleConnection
Tff7b72import T7ee787org.meshtastic.core.testing.FakeBleConnectionFactory
Tff7b72import T7ee787org.meshtastic.core.testing.FakeBleDevice
Tff7b72import T7ee787org.meshtastic.core.testing.FakeBleScanner
Tff7b72import T7ee787org.meshtastic.core.testing.FakeBleService
Tff7b72import T7ee787org.meshtastic.core.testing.FakeBluetoothRepository
Tff7b72import T7ee787kotlin.test.BeforeTest
Tff7b72import T7ee787kotlin.test.Test
Tff7b72import T7ee787kotlin.test.assertEquals
Tff7b72import T7ee787kotlin.test.assertTrue
Tff7b72import T7ee787kotlin.time.Duration

Tff7b72private Tff7b72const Tff7b72val Te6edf3BONDED_FALLBACK_CONNECT_BUFFER_MILLIS Tff7b72= T79c0ff1Te6edf3_000L
Tff7b72private Tff7b72val Te6edf3bondedScanTimeoutMillis Tff7b72= Te6edf3SCAN_TIMEOUTTb4b4b4.Te6edf3inWholeMilliseconds
Tff7b72private Tff7b72val Te6edf3bondedReconnectWindowMillis Tff7b72=
Te6edf3BleReconnectPolicyTb4b4b4.Te6edf3DEFAULT_SETTLE_DELAYTb4b4b4.Te6edf3inWholeMilliseconds Tff7b72+
Te6edf3bondedScanTimeoutMillis Tff7b72+
Te6edf3BONDED_FALLBACK_CONNECT_BUFFER_MILLIS

T8b949e/**
* Tests covering the BLE reconnect crash fixes in [BleRadioTransport]:
* 1. **CancellationException / GATT 133 fix**: [discoverServicesAndSetupCharacteristics] previously had a bare `catch
* (e: Exception)` that silently swallowed [CancellationException], meaning [BleConnection.disconnect] was never
* called when the scope was cancelled. This leaked the underlying BluetoothGatt handle and caused GATT status 133 on
* every subsequent reconnect. The fix adds an explicit `if (e is CancellationException)` branch that calls
* [disconnect] under [NonCancellable] before re-throwing.
* 2. **close() calls disconnect**: Verifies that calling [BleRadioTransport.close] triggers [BleConnection.disconnect]
* exactly once so the GATT handle is always released.
* 3. **Reconnect after failure respects policy backoff**: After a configurable number of consecutive failures the
* transport signals a transient (non-permanent) disconnect to the callback.
*/
Tf0883e@OptInTb4b4b4(Te6edf3ExperimentalCoroutinesApiTff7b72::Te6edf3classTb4b4b4)
Tff7b72class T56d364BleRadioTransportReconnectCrashTest Tb4b4b4{

Tff7b72private Tff7b72val Te6edf3scanner Tff7b72= Te6edf3FakeBleScannerTb4b4b4(Tb4b4b4)
Tff7b72private Tff7b72val Te6edf3bluetoothRepository Tff7b72= Te6edf3FakeBluetoothRepositoryTb4b4b4(Tb4b4b4)
Tff7b72private Tff7b72val Te6edf3connection Tff7b72= Te6edf3FakeBleConnectionTb4b4b4(Tb4b4b4)
Tff7b72private Tff7b72val Te6edf3connectionFactory Tff7b72= Te6edf3FakeBleConnectionFactoryTb4b4b4(Te6edf3connectionTb4b4b4)
Tff7b72private Tff7b72val Te6edf3service Tff7b72= Te6edf3mockTff7b72<Te6edf3orgTb4b4b4.Te6edf3meshtasticTb4b4b4.Te6edf3coreTb4b4b4.Te6edf3repositoryTb4b4b4.Te6edf3RadioInterfaceServiceTff7b72>Tb4b4b4(Te6edf3MockModeTb4b4b4.Te6edf3autofillTb4b4b4)
Tff7b72private Tff7b72val Te6edf3address Tff7b72= Ta5d6ff"Ta5d6ffAA:BB:CC:DD:EE:FFTa5d6ff"

Tf0883e@BeforeTest
Tff7b72fun Td2a8ffsetupTb4b4b4(Tb4b4b4) Tb4b4b4{
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3setHasPermissionsTb4b4b4(Tff7b72trueTb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3setBluetoothEnabledTb4b4b4(Tff7b72trueTb4b4b4)
Tb4b4b4}

T8b949e// ─── close() triggers disconnect ─────────────────────────────────────────────────────────────

T8b949e/**
* After [BleRadioTransport.close], [FakeBleConnection.disconnect] must be called.
*
* This validates the primary invariant introduced by the fix: GATT cleanup (disconnect) always runs β€” even when the
* coroutine scope is cancelled β€” by wrapping the call in [NonCancellable].
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`close calls disconnect to clean up GATT handle`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTest Tb4b4b4{
Tff7b72val Te6edf3device Tff7b72= Te6edf3FakeBleDeviceTb4b4b4(Te6edf3address Tff7b72= Te6edf3addressTb4b4b4, Te6edf3name Tff7b72= Ta5d6ff"Ta5d6ffTest RadioTa5d6ff"Tb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3bondTb4b4b4(Te6edf3deviceTb4b4b4)
Te6edf3scannerTb4b4b4.Te6edf3emitDeviceTb4b4b4(Te6edf3deviceTb4b4b4)

Tff7b72val Te6edf3bleTransport Tff7b72=
Te6edf3BleRadioTransportTb4b4b4(
Te6edf3scope Tff7b72= Tff7b72thisTb4b4b4,
Te6edf3scanner Tff7b72= Te6edf3scannerTb4b4b4,
Te6edf3bluetoothRepository Tff7b72= Te6edf3bluetoothRepositoryTb4b4b4,
Te6edf3connectionFactory Tff7b72= Te6edf3connectionFactoryTb4b4b4,
Te6edf3callback Tff7b72= Te6edf3serviceTb4b4b4,
Te6edf3address Tff7b72= Te6edf3addressTb4b4b4,
Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3startTb4b4b4(Tb4b4b4)

T8b949e// Allow the connection loop to reach the connected state.
Te6edf3advanceTimeByTb4b4b4(T79c0ff4Te6edf3_000LTb4b4b4)

Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)

T8b949e// disconnect() must be called: once by the connection loop teardown + once by close() itself.
T8b949e// We only assert it was called at least once β€” the exact count depends on timing.
Te6edf3assertTrueTb4b4b4(Te6edf3connectionTb4b4b4.Te6edf3disconnectCalls Tff7b72>Tff7b72= T79c0ff1Tb4b4b4, Ta5d6ff"Ta5d6ffExpected disconnect() to be called at least onceTa5d6ff"Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e// ─── disconnect called on connection failure ──────────────────────────────────────────────────

T8b949e/**
* When [FakeBleConnection.connectAndAwait] always returns [BleConnectionState.Disconnected], the transport must
* still eventually call [BleConnection.disconnect] to ensure the GATT handle state machine is reset before the next
* attempt.
*
* Virtual-time budget: DEFAULT_FAILURE_THRESHOLD (3) Γ— (3 s settle + backoff) β‰ˆ 24 s.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`disconnect is called on connection failure`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTest Tb4b4b4{
Tff7b72val Te6edf3device Tff7b72= Te6edf3FakeBleDeviceTb4b4b4(Te6edf3address Tff7b72= Te6edf3addressTb4b4b4, Te6edf3name Tff7b72= Ta5d6ff"Ta5d6ffTest RadioTa5d6ff"Tb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3bondTb4b4b4(Te6edf3deviceTb4b4b4)
Te6edf3scannerTb4b4b4.Te6edf3emitDeviceTb4b4b4(Te6edf3deviceTb4b4b4)

T8b949e// Make every connection attempt fail.
Te6edf3connectionTb4b4b4.Te6edf3failNextN Tff7b72= Tffa657IntTb4b4b4.Te6edf3MAX_VALUE

Tff7b72val Te6edf3bleTransport Tff7b72=
Te6edf3BleRadioTransportTb4b4b4(
Te6edf3scope Tff7b72= Tff7b72thisTb4b4b4,
Te6edf3scanner Tff7b72= Te6edf3scannerTb4b4b4,
Te6edf3bluetoothRepository Tff7b72= Te6edf3bluetoothRepositoryTb4b4b4,
Te6edf3connectionFactory Tff7b72= Te6edf3connectionFactoryTb4b4b4,
Te6edf3callback Tff7b72= Te6edf3serviceTb4b4b4,
Te6edf3address Tff7b72= Te6edf3addressTb4b4b4,
Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3startTb4b4b4(Tb4b4b4)

Te6edf3advanceTimeByTb4b4b4(T79c0ff3T79c0ff0Te6edf3_000LTb4b4b4)

Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)

T8b949e// Each failed connectAndAwait round-trips through the reconnect loop; close() always disconnects.
Te6edf3assertTrueTb4b4b4(Te6edf3connectionTb4b4b4.Te6edf3disconnectCalls Tff7b72>Tff7b72= T79c0ff1Tb4b4b4, Ta5d6ff"Ta5d6ffdisconnect() not called after connection failureTa5d6ff"Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e// ─── transient onDisconnect after failure threshold ──────────────────────────────────────────

T8b949e/**
* Mirrors [BleRadioTransportTest.`onDisconnect is called after DEFAULT_FAILURE_THRESHOLD consecutive failures`] but
* focuses specifically on the *reconnect* scenario introduced by the fix: after enough consecutive failures, the
* callback receives `isPermanent = false` β€” the transport keeps retrying rather than giving up permanently.
*
* Virtual time: 3 failures Γ— (3 s settle + backoff starting at 5 s) β‰ˆ 24 s.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`transient onDisconnect is signalled after failure threshold without giving up`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTest Tb4b4b4{
Tff7b72val Te6edf3device Tff7b72= Te6edf3FakeBleDeviceTb4b4b4(Te6edf3address Tff7b72= Te6edf3addressTb4b4b4, Te6edf3name Tff7b72= Ta5d6ff"Ta5d6ffTest RadioTa5d6ff"Tb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3bondTb4b4b4(Te6edf3deviceTb4b4b4)
Te6edf3scannerTb4b4b4.Te6edf3emitDeviceTb4b4b4(Te6edf3deviceTb4b4b4)

Te6edf3connectionTb4b4b4.Te6edf3connectException Tff7b72= Te6edf3orgTb4b4b4.Te6edf3meshtasticTb4b4b4.Te6edf3coreTb4b4b4.Te6edf3modelTb4b4b4.Te6edf3RadioNotConnectedExceptionTb4b4b4(Ta5d6ff"Ta5d6ffsimulated GATT failureTa5d6ff"Tb4b4b4)

Te6edf3every Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4, Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4} Te6edf3returns Tffa657Unit

Tff7b72val Te6edf3bleTransport Tff7b72=
Te6edf3BleRadioTransportTb4b4b4(
Te6edf3scope Tff7b72= Tff7b72thisTb4b4b4,
Te6edf3scanner Tff7b72= Te6edf3scannerTb4b4b4,
Te6edf3bluetoothRepository Tff7b72= Te6edf3bluetoothRepositoryTb4b4b4,
Te6edf3connectionFactory Tff7b72= Te6edf3connectionFactoryTb4b4b4,
Te6edf3callback Tff7b72= Te6edf3serviceTb4b4b4,
Te6edf3address Tff7b72= Te6edf3addressTb4b4b4,
Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3startTb4b4b4(Tb4b4b4)

Te6edf3advanceTimeByTb4b4b4(T79c0ff2T79c0ff4Te6edf3_001LTb4b4b4)

T8b949e// Transient disconnect must be signalled with NO user-facing error message β€” the
T8b949e// reconnect loop is still retrying, so a modal dialog would be confusing UX.
Te6edf3devTb4b4b4.Te6edf3mokkeryTb4b4b4.Te6edf3verify Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3isPermanent Tff7b72= Tff7b72falseTb4b4b4, Te6edf3errorMessage Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4}
T8b949e// Permanent disconnect must NEVER be called by the transport on its own.
Te6edf3devTb4b4b4.Te6edf3mokkeryTb4b4b4.Te6edf3verifyTb4b4b4(Te6edf3mode Tff7b72= Te6edf3devTb4b4b4.Te6edf3mokkeryTb4b4b4.Te6edf3verifyTb4b4b4.Te6edf3VerifyModeTb4b4b4.Te6edf3notTb4b4b4) Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3isPermanent Tff7b72= Tff7b72trueTb4b4b4, Te6edf3errorMessage Tff7b72= Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4)
Tb4b4b4}

Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e// ─── CancellationException is not silently swallowed ─────────────────────────────────────────

T8b949e/**
* [BleRadioTransport.close] cancels the [connectionScope]. The cancellation propagates as a [CancellationException]
* through the active coroutines in [discoverServicesAndSetupCharacteristics].
*
* Before the fix, `catch (e: Exception)` swallowed the [CancellationException] and the `disconnect()` call was
* skipped. After the fix, [disconnect] is called under [NonCancellable].
*
* This test uses a dedicated fake that throws [CancellationException] from [BleConnection.profile] to simulate the
* scope-cancellation path without races.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`disconnect is called when profile setup throws CancellationException`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTest Tb4b4b4{
Tff7b72val Te6edf3throwingConnection Tff7b72= Te6edf3CancellingProfileBleConnectionTb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3throwingFactory Tff7b72=
Tff7b72object Tb4b4b4: T56d364BleConnectionFactory Tb4b4b4{
Tff7b72override Tff7b72fun Td2a8ffcreateTb4b4b4(Te6edf3scopeTb4b4b4: Te6edf3CoroutineScopeTb4b4b4, Te6edf3tagTb4b4b4: Tffa657StringTb4b4b4)Tb4b4b4: Te6edf3BleConnection Tff7b72= Te6edf3throwingConnection
Tb4b4b4}
Tff7b72val Te6edf3device Tff7b72= Te6edf3FakeBleDeviceTb4b4b4(Te6edf3address Tff7b72= Te6edf3addressTb4b4b4, Te6edf3name Tff7b72= Ta5d6ff"Ta5d6ffTest RadioTa5d6ff"Tb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3bondTb4b4b4(Te6edf3deviceTb4b4b4)
Te6edf3scannerTb4b4b4.Te6edf3emitDeviceTb4b4b4(Te6edf3deviceTb4b4b4)

Tff7b72val Te6edf3bleTransport Tff7b72=
Te6edf3BleRadioTransportTb4b4b4(
Te6edf3scope Tff7b72= Tff7b72thisTb4b4b4,
Te6edf3scanner Tff7b72= Te6edf3scannerTb4b4b4,
Te6edf3bluetoothRepository Tff7b72= Te6edf3bluetoothRepositoryTb4b4b4,
Te6edf3connectionFactory Tff7b72= Te6edf3throwingFactoryTb4b4b4,
Te6edf3callback Tff7b72= Te6edf3serviceTb4b4b4,
Te6edf3address Tff7b72= Te6edf3addressTb4b4b4,
Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3startTb4b4b4(Tb4b4b4)

T8b949e// Allow one connection attempt to reach profile() and be cancelled.
Te6edf3advanceTimeByTb4b4b4(T79c0ff4Te6edf3_000LTb4b4b4)

Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)

Te6edf3assertTrueTb4b4b4(
Te6edf3throwingConnectionTb4b4b4.Te6edf3disconnectCalls Tff7b72>Tff7b72= T79c0ff1Tb4b4b4,
Ta5d6ff"Ta5d6ffdisconnect() must be called after CancellationException in profile() β€” GATT leak fixTa5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e// ─── Reconnect after a stable connection drops ───────────────────────────────────────────────

T8b949e/**
* Regression test for the BLE reconnect hang.
*
* Symptom: after a stable connection (uptime > minStableConnection) was terminated by a remote disconnect (e.g.
* node power-cycle), the transport's reconnect loop never iterated β€” `attemptConnection` ran exactly once, the GATT
* disconnect callback fired, and then nothing.
*
* Root cause: `attemptConnection` wrapped its disconnect-watcher in a `coroutineScope {
* connectionState.onEach{...}.launchIn(this); connectionState.first { Disconnected } }` block. `coroutineScope`
* waits for ALL launched children before returning, but the `.launchIn` collector on a hot `StateFlow` (or
* `SharedFlow(replay=1)`) never completes naturally. After `.first` returned, the scope hung forever, blocking
* `BleReconnectPolicy.execute` from issuing the next attempt.
*
* This test exercises the full happy-path reconnect cycle: connect β†’ stable uptime β†’ external disconnect β†’ expect a
* second `connectAndAwait` call. With the bug present, only one `connectAndAwait` call ever happens.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`transport reconnects after a stable connection is dropped remotely`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTest Tb4b4b4{
Tff7b72val Te6edf3device Tff7b72= Te6edf3FakeBleDeviceTb4b4b4(Te6edf3address Tff7b72= Te6edf3addressTb4b4b4, Te6edf3name Tff7b72= Ta5d6ff"Ta5d6ffTest RadioTa5d6ff"Tb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3bondTb4b4b4(Te6edf3deviceTb4b4b4)
Te6edf3scannerTb4b4b4.Te6edf3emitDeviceTb4b4b4(Te6edf3deviceTb4b4b4)

Tff7b72val Te6edf3bleTransport Tff7b72=
Te6edf3BleRadioTransportTb4b4b4(
Te6edf3scope Tff7b72= Tff7b72thisTb4b4b4,
Te6edf3scanner Tff7b72= Te6edf3scannerTb4b4b4,
Te6edf3bluetoothRepository Tff7b72= Te6edf3bluetoothRepositoryTb4b4b4,
Te6edf3connectionFactory Tff7b72= Te6edf3connectionFactoryTb4b4b4,
Te6edf3callback Tff7b72= Te6edf3serviceTb4b4b4,
Te6edf3address Tff7b72= Te6edf3addressTb4b4b4,
Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3startTb4b4b4(Tb4b4b4)

T8b949e// Settle delay (3 s) + connect + handshake.
Te6edf3advanceTimeByTb4b4b4(T79c0ff4Te6edf3_000LTb4b4b4)
Te6edf3assertTrueTb4b4b4(Te6edf3connectionTb4b4b4.Te6edf3connectAndAwaitCalls Tff7b72=Tff7b72= T79c0ff1Tb4b4b4, Ta5d6ff"Ta5d6ffFirst connect must happen during initial start windowTa5d6ff"Tb4b4b4)

T8b949e// Stay connected long enough to be considered stable (> minStableConnection = 5 s).
Te6edf3advanceTimeByTb4b4b4(T79c0ff1T79c0ff0Te6edf3_000LTb4b4b4)

T8b949e// Simulate the firmware dying mid-session β€” the same path a node power-cycle takes.
Te6edf3connectionTb4b4b4.Te6edf3simulateRemoteDisconnectTb4b4b4(Te6edf3reason Tff7b72= Te6edf3DisconnectReasonTb4b4b4.Te6edf3TimeoutTb4b4b4)

T8b949e// Settle delay (3 s) before the next attempt + re-connect window. Generous to absorb
T8b949e// the policy retry backoff (5 s on first failure) plus another 3 s settle delay.
Te6edf3advanceTimeByTb4b4b4(T79c0ff3T79c0ff0Te6edf3_000LTb4b4b4)

Te6edf3assertTrueTb4b4b4(
Te6edf3connectionTb4b4b4.Te6edf3connectAndAwaitCalls Tff7b72>Tff7b72= T79c0ff2Tb4b4b4,
Ta5d6ff"Ta5d6ffReconnect loop must call connectAndAwait again after a remote disconnect Ta5d6ff" Tff7b72+
Ta5d6ff"Ta5d6ff(actual calls: Tffd700${Te6edf3connectionTb4b4b4.Te6edf3connectAndAwaitCallsTffd700}Ta5d6ff)Ta5d6ff"Tb4b4b4,
Tb4b4b4)

Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

Tf0883e@Test
Tff7b72fun Td2a8ff`retired BLE profile cannot dispatch inbound packets after reconnect`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTest Tb4b4b4{
Tff7b72val Te6edf3device Tff7b72= Te6edf3FakeBleDeviceTb4b4b4(Te6edf3address Tff7b72= Te6edf3addressTb4b4b4, Te6edf3name Tff7b72= Ta5d6ff"Ta5d6ffTest RadioTa5d6ff"Tb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3bondTb4b4b4(Te6edf3deviceTb4b4b4)
Te6edf3scannerTb4b4b4.Te6edf3emitDeviceTb4b4b4(Te6edf3deviceTb4b4b4)
Tff7b72val Te6edf3retiredService Tff7b72= Te6edf3connectionTb4b4b4.Te6edf3service
Tff7b72val Te6edf3replacementService Tff7b72= Te6edf3FakeBleServiceTb4b4b4(Tb4b4b4)
Te6edf3listOfTb4b4b4(Te6edf3retiredServiceTb4b4b4, Te6edf3replacementServiceTb4b4b4)Tb4b4b4.Te6edf3forEach Tb4b4b4{ Te6edf3bleService Tff7b72-Tff7b72>
Te6edf3bleServiceTb4b4b4.Te6edf3addCharacteristicTb4b4b4(Te6edf3FROMNUM_CHARACTERISTICTb4b4b4)
Te6edf3bleServiceTb4b4b4.Te6edf3addCharacteristicTb4b4b4(Te6edf3FROMRADIO_CHARACTERISTICTb4b4b4)
Tb4b4b4}
Te6edf3connectionTb4b4b4.Te6edf3profileServiceProvider Tff7b72= Tb4b4b4{ Te6edf3call Tff7b72-Tff7b72> Tff7b72if Tb4b4b4(Te6edf3call Tff7b72=Tff7b72= T79c0ff1Tb4b4b4) Te6edf3retiredService Tff7b72else Te6edf3replacementService Tb4b4b4}
Tff7b72var Te6edf3dispatchedPackets Tff7b72= T79c0ff0
Te6edf3every Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3handleFromRadioTb4b4b4(Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4} Te6edf3calls Tb4b4b4{ Te6edf3dispatchedPacketsTff7b72+Tff7b72+ Tb4b4b4}

Tff7b72val Te6edf3bleTransport Tff7b72=
Te6edf3BleRadioTransportTb4b4b4(
Te6edf3scope Tff7b72= Tff7b72thisTb4b4b4,
Te6edf3scanner Tff7b72= Te6edf3scannerTb4b4b4,
Te6edf3bluetoothRepository Tff7b72= Te6edf3bluetoothRepositoryTb4b4b4,
Te6edf3connectionFactory Tff7b72= Te6edf3connectionFactoryTb4b4b4,
Te6edf3callback Tff7b72= Te6edf3serviceTb4b4b4,
Te6edf3address Tff7b72= Te6edf3addressTb4b4b4,
Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3startTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff4Te6edf3_000LTb4b4b4)
T8b949e// Virtual time lets profile setup settle; stability is based on wall-clock uptime instead.
Te6edf3advanceTimeByTb4b4b4(T79c0ff6Te6edf3_000LTb4b4b4)

Te6edf3connectionTb4b4b4.Te6edf3simulateRemoteDisconnectTb4b4b4(Te6edf3reason Tff7b72= Te6edf3DisconnectReasonTb4b4b4.Te6edf3TimeoutTb4b4b4)
Te6edf3testSchedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
T8b949e// Connection uptime is wall-clock time (nowMillis), so a slow host can classify this disconnect as
T8b949e// stable instead of unstable. Budget enough virtual time to cover either retry path before asserting.
Te6edf3advanceTimeByTb4b4b4(T79c0ff3T79c0ff0Te6edf3_000LTb4b4b4)
Te6edf3assertTrueTb4b4b4(
Te6edf3connectionTb4b4b4.Te6edf3profileCalls Tff7b72>Tff7b72= T79c0ff2Tb4b4b4,
Ta5d6ff"Ta5d6ffReconnect must publish a replacement BLE profile (actual: Tffd700${Te6edf3connectionTb4b4b4.Te6edf3profileCallsTffd700}Ta5d6ff)Ta5d6ff"Tb4b4b4,
Tb4b4b4)

T8b949e// FakeBleConnection intentionally leaves the retired profile collector active. Drive each profile's
T8b949e// service independently so shared queue draining cannot decide which generation consumes the packet.
Te6edf3retiredServiceTb4b4b4.Te6edf3enqueueReadTb4b4b4(Te6edf3FROMRADIO_CHARACTERISTICTb4b4b4, Te6edf3byteArrayOfTb4b4b4(T79c0ff1Tb4b4b4, T79c0ff2Tb4b4b4, T79c0ff3Tb4b4b4)Tb4b4b4)
Te6edf3retiredServiceTb4b4b4.Te6edf3emitNotificationTb4b4b4(Te6edf3FROMNUM_CHARACTERISTICTb4b4b4, Te6edf3byteArrayOfTb4b4b4(T79c0ff1Tb4b4b4)Tb4b4b4)
Te6edf3testSchedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(T79c0ff0Tb4b4b4, Te6edf3dispatchedPacketsTb4b4b4, Ta5d6ff"Ta5d6ffThe retired BLE profile must not dispatch a late inbound packetTa5d6ff"Tb4b4b4)

Te6edf3replacementServiceTb4b4b4.Te6edf3enqueueReadTb4b4b4(Te6edf3FROMRADIO_CHARACTERISTICTb4b4b4, Te6edf3byteArrayOfTb4b4b4(T79c0ff4Tb4b4b4, T79c0ff5Tb4b4b4, T79c0ff6Tb4b4b4)Tb4b4b4)
Te6edf3replacementServiceTb4b4b4.Te6edf3emitNotificationTb4b4b4(Te6edf3FROMNUM_CHARACTERISTICTb4b4b4, Te6edf3byteArrayOfTb4b4b4(T79c0ff1Tb4b4b4)Tb4b4b4)
Te6edf3testSchedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3dispatchedPacketsTb4b4b4, Ta5d6ff"Ta5d6ffThe replacement BLE profile must still dispatch inbound packetsTa5d6ff"Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e// ─── Session-failure recovery ────────────────────────────────────────────────────────────────

Tf0883e@Test
Tff7b72fun Td2a8ff`write failure while connected clears state and triggers reconnect`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTest Tb4b4b4{
Tff7b72val Te6edf3device Tff7b72= Te6edf3FakeBleDeviceTb4b4b4(Te6edf3address Tff7b72= Te6edf3addressTb4b4b4, Te6edf3name Tff7b72= Ta5d6ff"Ta5d6ffTest RadioTa5d6ff"Tb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3bondTb4b4b4(Te6edf3deviceTb4b4b4)
Te6edf3scannerTb4b4b4.Te6edf3emitDeviceTb4b4b4(Te6edf3deviceTb4b4b4)

Tff7b72val Te6edf3bleTransport Tff7b72=
Te6edf3BleRadioTransportTb4b4b4(
Te6edf3scope Tff7b72= Tff7b72thisTb4b4b4,
Te6edf3scanner Tff7b72= Te6edf3scannerTb4b4b4,
Te6edf3bluetoothRepository Tff7b72= Te6edf3bluetoothRepositoryTb4b4b4,
Te6edf3connectionFactory Tff7b72= Te6edf3connectionFactoryTb4b4b4,
Te6edf3callback Tff7b72= Te6edf3serviceTb4b4b4,
Te6edf3address Tff7b72= Te6edf3addressTb4b4b4,
Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3startTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff4Te6edf3_000LTb4b4b4) T8b949e// connect + handshake
Te6edf3assertTrueTb4b4b4(Te6edf3connectionTb4b4b4.Te6edf3connectAndAwaitCalls Tff7b72=Tff7b72= T79c0ff1Tb4b4b4, Ta5d6ff"Ta5d6ffFirst connect must happenTa5d6ff"Tb4b4b4)

T8b949e// Inject write failure while BLE state remains Connected
Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3writeException Tff7b72= Te6edf3NotConnectedExceptionTb4b4b4(Ta5d6ff"Ta5d6ffsession closedTa5d6ff"Tb4b4b4)

T8b949e// Trigger a write (simulating a heartbeat or user packet)
Te6edf3bleTransportTb4b4b4.Te6edf3handleSendToRadioTb4b4b4(Te6edf3byteArrayOfTb4b4b4(T79c0ff1Tb4b4b4, T79c0ff2Tb4b4b4, T79c0ff3Tb4b4b4)Tb4b4b4)
T8b949e// Advance through retryBleOperation's 3 retries (~750ms backoff) so the write failure
T8b949e// reaches handleFailure and forces disconnect. Stay under the 3s reconnect settle delay.
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

T8b949e// After failure: disconnect must be called (GATT cleanup)
Te6edf3assertTrueTb4b4b4(Te6edf3connectionTb4b4b4.Te6edf3disconnectCalls Tff7b72>Tff7b72= T79c0ff1Tb4b4b4, Ta5d6ff"Ta5d6ffdisconnect() must be called after write failureTa5d6ff"Tb4b4b4)

T8b949e// Reconnect policy should iterate β€” wait for settle (3s) + connect
Te6edf3advanceTimeByTb4b4b4(T79c0ff1T79c0ff0Te6edf3_000LTb4b4b4)
Te6edf3assertTrueTb4b4b4(
Te6edf3connectionTb4b4b4.Te6edf3connectAndAwaitCalls Tff7b72>Tff7b72= T79c0ff2Tb4b4b4,
Ta5d6ff"Ta5d6ffReconnect loop must call connectAndAwait again after session failure Ta5d6ff" Tff7b72+
Ta5d6ff"Ta5d6ff(actual calls: Tffd700${Te6edf3connectionTb4b4b4.Te6edf3connectAndAwaitCallsTffd700}Ta5d6ff)Ta5d6ff"Tb4b4b4,
Tb4b4b4)

Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

Tf0883e@Test
Tff7b72fun Td2a8ff`CancellationException from write does not trigger callback`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTest Tb4b4b4{
Tff7b72val Te6edf3device Tff7b72= Te6edf3FakeBleDeviceTb4b4b4(Te6edf3address Tff7b72= Te6edf3addressTb4b4b4, Te6edf3name Tff7b72= Ta5d6ff"Ta5d6ffTest RadioTa5d6ff"Tb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3bondTb4b4b4(Te6edf3deviceTb4b4b4)
Te6edf3scannerTb4b4b4.Te6edf3emitDeviceTb4b4b4(Te6edf3deviceTb4b4b4)

Te6edf3every Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4, Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4} Te6edf3returns Tffa657Unit

Tff7b72val Te6edf3bleTransport Tff7b72=
Te6edf3BleRadioTransportTb4b4b4(
Te6edf3scope Tff7b72= Tff7b72thisTb4b4b4,
Te6edf3scanner Tff7b72= Te6edf3scannerTb4b4b4,
Te6edf3bluetoothRepository Tff7b72= Te6edf3bluetoothRepositoryTb4b4b4,
Te6edf3connectionFactory Tff7b72= Te6edf3connectionFactoryTb4b4b4,
Te6edf3callback Tff7b72= Te6edf3serviceTb4b4b4,
Te6edf3address Tff7b72= Te6edf3addressTb4b4b4,
Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3startTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff4Te6edf3_000LTb4b4b4)

Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3writeException Tff7b72= Te6edf3CancellationExceptionTb4b4b4(Ta5d6ff"Ta5d6ffcancelledTa5d6ff"Tb4b4b4)

Te6edf3bleTransportTb4b4b4.Te6edf3handleSendToRadioTb4b4b4(Te6edf3byteArrayOfTb4b4b4(T79c0ff1Tb4b4b4)Tb4b4b4)
T8b949e// Drain the cancelled write coroutine; no retry is triggered for CancellationException.
Te6edf3testSchedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)

T8b949e// CancellationException must NOT result in a user-facing disconnect callback
Te6edf3devTb4b4b4.Te6edf3mokkeryTb4b4b4.Te6edf3verifyTb4b4b4(Te6edf3mode Tff7b72= Te6edf3devTb4b4b4.Te6edf3mokkeryTb4b4b4.Te6edf3verifyTb4b4b4.Te6edf3VerifyModeTb4b4b4.Te6edf3notTb4b4b4) Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4, Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4}

Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

Tf0883e@Test
Tff7b72fun Td2a8ff`write retry stops when remote disconnect retires the captured session`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTest Tb4b4b4{
Tff7b72val Te6edf3device Tff7b72= Te6edf3FakeBleDeviceTb4b4b4(Te6edf3address Tff7b72= Te6edf3addressTb4b4b4, Te6edf3name Tff7b72= Ta5d6ff"Ta5d6ffTest RadioTa5d6ff"Tb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3bondTb4b4b4(Te6edf3deviceTb4b4b4)
Te6edf3scannerTb4b4b4.Te6edf3emitDeviceTb4b4b4(Te6edf3deviceTb4b4b4)

Tff7b72val Te6edf3bleTransport Tff7b72=
Te6edf3BleRadioTransportTb4b4b4(
Te6edf3scope Tff7b72= Tff7b72thisTb4b4b4,
Te6edf3scanner Tff7b72= Te6edf3scannerTb4b4b4,
Te6edf3bluetoothRepository Tff7b72= Te6edf3bluetoothRepositoryTb4b4b4,
Te6edf3connectionFactory Tff7b72= Te6edf3connectionFactoryTb4b4b4,
Te6edf3callback Tff7b72= Te6edf3serviceTb4b4b4,
Te6edf3address Tff7b72= Te6edf3addressTb4b4b4,
Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3startTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff4Te6edf3_000LTb4b4b4)

Tff7b72val Te6edf3attemptsBefore Tff7b72= Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3writeAttempts
Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3writeException Tff7b72= Te6edf3NotConnectedExceptionTb4b4b4(Ta5d6ff"Ta5d6ffsession closedTa5d6ff"Tb4b4b4)
Te6edf3bleTransportTb4b4b4.Te6edf3handleSendToRadioTb4b4b4(Te6edf3byteArrayOfTb4b4b4(T79c0ff1Tb4b4b4)Tb4b4b4)
Te6edf3testSchedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(Te6edf3attemptsBefore Tff7b72+ T79c0ff1Tb4b4b4, Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3writeAttemptsTb4b4b4)

Te6edf3connectionTb4b4b4.Te6edf3simulateRemoteDisconnectTb4b4b4(Tb4b4b4)
Te6edf3testSchedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertEqualsTb4b4b4(
Te6edf3attemptsBefore Tff7b72+ T79c0ff1Tb4b4b4,
Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3writeAttemptsTb4b4b4,
Ta5d6ff"Ta5d6ffA write captured from a retired session must not consume more retry attemptsTa5d6ff"Tb4b4b4,
Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

Tf0883e@Test
Tff7b72fun Td2a8ff`repeated writes after session failure do not spam onDisconnect`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTest Tb4b4b4{
Tff7b72val Te6edf3device Tff7b72= Te6edf3FakeBleDeviceTb4b4b4(Te6edf3address Tff7b72= Te6edf3addressTb4b4b4, Te6edf3name Tff7b72= Ta5d6ff"Ta5d6ffTest RadioTa5d6ff"Tb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3bondTb4b4b4(Te6edf3deviceTb4b4b4)
Te6edf3scannerTb4b4b4.Te6edf3emitDeviceTb4b4b4(Te6edf3deviceTb4b4b4)

Tff7b72var Te6edf3onDisconnectCalls Tff7b72= T79c0ff0
Te6edf3every Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4, Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4} Te6edf3calls Tb4b4b4{ Te6edf3onDisconnectCallsTff7b72+Tff7b72+ Tb4b4b4}

Tff7b72val Te6edf3bleTransport Tff7b72=
Te6edf3BleRadioTransportTb4b4b4(
Te6edf3scope Tff7b72= Tff7b72thisTb4b4b4,
Te6edf3scanner Tff7b72= Te6edf3scannerTb4b4b4,
Te6edf3bluetoothRepository Tff7b72= Te6edf3bluetoothRepositoryTb4b4b4,
Te6edf3connectionFactory Tff7b72= Te6edf3connectionFactoryTb4b4b4,
Te6edf3callback Tff7b72= Te6edf3serviceTb4b4b4,
Te6edf3address Tff7b72= Te6edf3addressTb4b4b4,
Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3startTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff4Te6edf3_000LTb4b4b4)

T8b949e// First write failure β€” should trigger onDisconnect once
Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3writeException Tff7b72= Te6edf3NotConnectedExceptionTb4b4b4(Ta5d6ff"Ta5d6ffsession closedTa5d6ff"Tb4b4b4)
Te6edf3bleTransportTb4b4b4.Te6edf3handleSendToRadioTb4b4b4(Te6edf3byteArrayOfTb4b4b4(T79c0ff1Tb4b4b4)Tb4b4b4)
T8b949e// Advance enough for retryBleOperation to exhaust 3 retries (~750ms max) + handleFailure +
T8b949e// disconnect, but NOT enough to reach the 3 s reconnect settle delay.
Te6edf3advanceTimeByTb4b4b4(T79c0ff2Te6edf3_000LTb4b4b4)

T8b949e// Second write failure against same session β€” must NOT trigger another callback
Te6edf3bleTransportTb4b4b4.Te6edf3handleSendToRadioTb4b4b4(Te6edf3byteArrayOfTb4b4b4(T79c0ff2Tb4b4b4)Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3onDisconnectCallsTb4b4b4, Ta5d6ff"Ta5d6ffonDisconnect must be called exactly once for the session failureTa5d6ff"Tb4b4b4)

Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

Tf0883e@Test
Tff7b72fun Td2a8ff`stale radioService is cleared after session failure`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTest Tb4b4b4{
Tff7b72val Te6edf3device Tff7b72= Te6edf3FakeBleDeviceTb4b4b4(Te6edf3address Tff7b72= Te6edf3addressTb4b4b4, Te6edf3name Tff7b72= Ta5d6ff"Ta5d6ffTest RadioTa5d6ff"Tb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3bondTb4b4b4(Te6edf3deviceTb4b4b4)
Te6edf3scannerTb4b4b4.Te6edf3emitDeviceTb4b4b4(Te6edf3deviceTb4b4b4)

Tff7b72val Te6edf3bleTransport Tff7b72=
Te6edf3BleRadioTransportTb4b4b4(
Te6edf3scope Tff7b72= Tff7b72thisTb4b4b4,
Te6edf3scanner Tff7b72= Te6edf3scannerTb4b4b4,
Te6edf3bluetoothRepository Tff7b72= Te6edf3bluetoothRepositoryTb4b4b4,
Te6edf3connectionFactory Tff7b72= Te6edf3connectionFactoryTb4b4b4,
Te6edf3callback Tff7b72= Te6edf3serviceTb4b4b4,
Te6edf3address Tff7b72= Te6edf3addressTb4b4b4,
Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3startTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff4Te6edf3_000LTb4b4b4)

Tff7b72val Te6edf3writesBefore Tff7b72= Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3writesTb4b4b4.Te6edf3size

T8b949e// First write failure clears radioService
Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3writeException Tff7b72= Te6edf3NotConnectedExceptionTb4b4b4(Ta5d6ff"Ta5d6ffsession closedTa5d6ff"Tb4b4b4)
Te6edf3bleTransportTb4b4b4.Te6edf3handleSendToRadioTb4b4b4(Te6edf3byteArrayOfTb4b4b4(T79c0ff1Tb4b4b4, T79c0ff2Tb4b4b4, T79c0ff3Tb4b4b4)Tb4b4b4)
T8b949e// Advance enough for retryBleOperation to exhaust 3 retries (~750ms max) + handleFailure +
T8b949e// disconnect, but NOT enough to reach the 3 s reconnect settle delay.
Te6edf3advanceTimeByTb4b4b4(T79c0ff2Te6edf3_000LTb4b4b4)

T8b949e// Second write β€” radioService should be null, so no write is attempted
Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3writeException Tff7b72= Tff7b72null T8b949e// clear the exception hook
Te6edf3bleTransportTb4b4b4.Te6edf3handleSendToRadioTb4b4b4(Te6edf3byteArrayOfTb4b4b4(T79c0ff4Tb4b4b4, T79c0ff5Tb4b4b4, T79c0ff6Tb4b4b4)Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

T8b949e// No new writes should have been recorded (radioService was null β†’ write skipped)
Te6edf3assertEqualsTb4b4b4(
Te6edf3writesBeforeTb4b4b4,
Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3writesTb4b4b4.Te6edf3sizeTb4b4b4,
Ta5d6ff"Ta5d6ffNo new write should be recorded after radioService was clearedTa5d6ff"Tb4b4b4,
Tb4b4b4)

Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

Tf0883e@Test
Tff7b72fun Td2a8ff`internal session failure is not treated as intentional disconnect`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTest Tb4b4b4{
Tff7b72val Te6edf3device Tff7b72= Te6edf3FakeBleDeviceTb4b4b4(Te6edf3address Tff7b72= Te6edf3addressTb4b4b4, Te6edf3name Tff7b72= Ta5d6ff"Ta5d6ffTest RadioTa5d6ff"Tb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3bondTb4b4b4(Te6edf3deviceTb4b4b4)
Te6edf3scannerTb4b4b4.Te6edf3emitDeviceTb4b4b4(Te6edf3deviceTb4b4b4)

Te6edf3every Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4, Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4} Te6edf3returns Tffa657Unit

Tff7b72val Te6edf3bleTransport Tff7b72=
Te6edf3BleRadioTransportTb4b4b4(
Te6edf3scope Tff7b72= Tff7b72thisTb4b4b4,
Te6edf3scanner Tff7b72= Te6edf3scannerTb4b4b4,
Te6edf3bluetoothRepository Tff7b72= Te6edf3bluetoothRepositoryTb4b4b4,
Te6edf3connectionFactory Tff7b72= Te6edf3connectionFactoryTb4b4b4,
Te6edf3callback Tff7b72= Te6edf3serviceTb4b4b4,
Te6edf3address Tff7b72= Te6edf3addressTb4b4b4,
Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3startTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff4Te6edf3_000LTb4b4b4) T8b949e// connect + handshake

T8b949e// Inject a write failure β€” this should NOT be treated as intentional
Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3writeException Tff7b72= Te6edf3NotConnectedExceptionTb4b4b4(Ta5d6ff"Ta5d6ffsession closedTa5d6ff"Tb4b4b4)
Te6edf3bleTransportTb4b4b4.Te6edf3handleSendToRadioTb4b4b4(Te6edf3byteArrayOfTb4b4b4(T79c0ff1Tb4b4b4, T79c0ff2Tb4b4b4, T79c0ff3Tb4b4b4)Tb4b4b4)
T8b949e// Advance through retryBleOperation's 3 retries (~750ms backoff) so the write failure
T8b949e// reaches handleFailure and forces disconnect.
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

T8b949e// disconnect must have been called (forced cleanup)
Te6edf3assertTrueTb4b4b4(Te6edf3connectionTb4b4b4.Te6edf3disconnectCalls Tff7b72>Tff7b72= T79c0ff1Tb4b4b4, Ta5d6ff"Ta5d6ffdisconnect() must be called after write failureTa5d6ff"Tb4b4b4)

T8b949e// Verify onDisconnect was called with isPermanent = false and NO user-facing error β€”
T8b949e// non-permanent session failures auto-recover, so no modal dialog should be shown.
Te6edf3devTb4b4b4.Te6edf3mokkeryTb4b4b4.Te6edf3verify Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3isPermanent Tff7b72= Tff7b72falseTb4b4b4, Te6edf3errorMessage Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4}
Te6edf3devTb4b4b4.Te6edf3mokkeryTb4b4b4.Te6edf3verifyTb4b4b4(Te6edf3mode Tff7b72= Te6edf3devTb4b4b4.Te6edf3mokkeryTb4b4b4.Te6edf3verifyTb4b4b4.Te6edf3VerifyModeTb4b4b4.Te6edf3notTb4b4b4) Tb4b4b4{
Te6edf3serviceTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3isPermanent Tff7b72= Tff7b72trueTb4b4b4, Te6edf3errorMessage Tff7b72= Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4)
Tb4b4b4}

T8b949e// Reconnect should happen (policy should iterate)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1T79c0ff5Te6edf3_000LTb4b4b4)
Te6edf3assertTrueTb4b4b4(
Te6edf3connectionTb4b4b4.Te6edf3connectAndAwaitCalls Tff7b72>Tff7b72= T79c0ff2Tb4b4b4,
Ta5d6ff"Ta5d6ffReconnect loop must iterate after internal session failureTa5d6ff"Tb4b4b4,
Tb4b4b4)

Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e// ─── Liveness restart semantics ──────────────────────────────────────────────────────────────

T8b949e/**
* Validates the transport-level behavior that liveness restart depends on: after stop (close) + start, a new
* connection attempt must occur.
*
* The [SharedRadioInterfaceService.checkLiveness] path calls stopTransportLocked then startTransportLocked, which
* destroys and recreates the transport. This test verifies that BleRadioTransport correctly handles this stop/start
* cycle.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`stop and restart creates a new connection attempt`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTest Tb4b4b4{
Tff7b72val Te6edf3device Tff7b72= Te6edf3FakeBleDeviceTb4b4b4(Te6edf3address Tff7b72= Te6edf3addressTb4b4b4, Te6edf3name Tff7b72= Ta5d6ff"Ta5d6ffTest RadioTa5d6ff"Tb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3bondTb4b4b4(Te6edf3deviceTb4b4b4)
Te6edf3scannerTb4b4b4.Te6edf3emitDeviceTb4b4b4(Te6edf3deviceTb4b4b4)

Tff7b72val Te6edf3bleTransport Tff7b72=
Te6edf3BleRadioTransportTb4b4b4(
Te6edf3scope Tff7b72= Tff7b72thisTb4b4b4,
Te6edf3scanner Tff7b72= Te6edf3scannerTb4b4b4,
Te6edf3bluetoothRepository Tff7b72= Te6edf3bluetoothRepositoryTb4b4b4,
Te6edf3connectionFactory Tff7b72= Te6edf3connectionFactoryTb4b4b4,
Te6edf3callback Tff7b72= Te6edf3serviceTb4b4b4,
Te6edf3address Tff7b72= Te6edf3addressTb4b4b4,
Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3startTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff4Te6edf3_000LTb4b4b4)
Te6edf3assertTrueTb4b4b4(Te6edf3connectionTb4b4b4.Te6edf3connectAndAwaitCalls Tff7b72=Tff7b72= T79c0ff1Tb4b4b4, Ta5d6ff"Ta5d6ffFirst connect must happenTa5d6ff"Tb4b4b4)

T8b949e// Simulate liveness restart: close + create fresh transport
Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff2Te6edf3_000LTb4b4b4)

Tff7b72val Te6edf3freshConnection Tff7b72= Te6edf3FakeBleConnectionTb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3freshFactory Tff7b72= Te6edf3FakeBleConnectionFactoryTb4b4b4(Te6edf3freshConnectionTb4b4b4)

Tff7b72val Te6edf3restartedTransport Tff7b72=
Te6edf3BleRadioTransportTb4b4b4(
Te6edf3scope Tff7b72= Tff7b72thisTb4b4b4,
Te6edf3scanner Tff7b72= Te6edf3scannerTb4b4b4,
Te6edf3bluetoothRepository Tff7b72= Te6edf3bluetoothRepositoryTb4b4b4,
Te6edf3connectionFactory Tff7b72= Te6edf3freshFactoryTb4b4b4,
Te6edf3callback Tff7b72= Te6edf3serviceTb4b4b4,
Te6edf3address Tff7b72= Te6edf3addressTb4b4b4,
Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3restartedTransportTb4b4b4.Te6edf3startTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff4Te6edf3_000LTb4b4b4)

Te6edf3assertTrueTb4b4b4(
Te6edf3freshConnectionTb4b4b4.Te6edf3connectAndAwaitCalls Tff7b72>Tff7b72= T79c0ff1Tb4b4b4,
Ta5d6ff"Ta5d6ffFresh transport must attempt connection after restartTa5d6ff"Tb4b4b4,
Tb4b4b4)

Te6edf3restartedTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3restartedTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e// ─── Profile setup failure returns Failed outcome and retries ──────────────────────────────────

T8b949e/**
* When profile setup fails (e.g. missing service UUID), the transport should return a
* [BleReconnectPolicy.Outcome.Failed] and the reconnect loop should iterate. Uses
* [FakeBleConnection.missingServices] to cause `profile()` to throw.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`profile setup failure returns Failed outcome and retries`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTest Tb4b4b4{
Tff7b72val Te6edf3device Tff7b72= Te6edf3FakeBleDeviceTb4b4b4(Te6edf3address Tff7b72= Te6edf3addressTb4b4b4, Te6edf3name Tff7b72= Ta5d6ff"Ta5d6ffTest RadioTa5d6ff"Tb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3bondTb4b4b4(Te6edf3deviceTb4b4b4)
Te6edf3scannerTb4b4b4.Te6edf3emitDeviceTb4b4b4(Te6edf3deviceTb4b4b4)

T8b949e// Make SERVICE_UUID missing β†’ profile() throws NoSuchElementException
Te6edf3connectionTb4b4b4.Te6edf3missingServicesTb4b4b4.Te6edf3addTb4b4b4(Te6edf3SERVICE_UUIDTb4b4b4)

Tff7b72val Te6edf3bleTransport Tff7b72=
Te6edf3BleRadioTransportTb4b4b4(
Te6edf3scope Tff7b72= Tff7b72thisTb4b4b4,
Te6edf3scanner Tff7b72= Te6edf3scannerTb4b4b4,
Te6edf3bluetoothRepository Tff7b72= Te6edf3bluetoothRepositoryTb4b4b4,
Te6edf3connectionFactory Tff7b72= Te6edf3connectionFactoryTb4b4b4,
Te6edf3callback Tff7b72= Te6edf3serviceTb4b4b4,
Te6edf3address Tff7b72= Te6edf3addressTb4b4b4,
Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3startTb4b4b4(Tb4b4b4)

T8b949e// First attempt fails at profile setup, then settle delay + reconnect.
Te6edf3advanceTimeByTb4b4b4(T79c0ff1T79c0ff5Te6edf3_000LTb4b4b4)

T8b949e// The reconnect loop should have called connectAndAwait at least twice
T8b949e// (initial failure + retry)
Te6edf3assertTrueTb4b4b4(
Te6edf3connectionTb4b4b4.Te6edf3connectAndAwaitCalls Tff7b72>Tff7b72= T79c0ff2Tb4b4b4,
Ta5d6ff"Ta5d6ffReconnect loop must iterate after profile setup failure Ta5d6ff" Tff7b72+
Ta5d6ff"Ta5d6ff(actual calls: Tffd700${Te6edf3connectionTb4b4b4.Te6edf3connectAndAwaitCallsTffd700}Ta5d6ff)Ta5d6ff"Tb4b4b4,
Tb4b4b4)

Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Regression: CancellationException from a write should not trigger onDisconnect even after the transport has
* reconnected and is operating normally.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`CancellationException from write does not trigger onDisconnect after reconnection`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTest Tb4b4b4{
Tff7b72val Te6edf3device Tff7b72= Te6edf3FakeBleDeviceTb4b4b4(Te6edf3address Tff7b72= Te6edf3addressTb4b4b4, Te6edf3name Tff7b72= Ta5d6ff"Ta5d6ffTest RadioTa5d6ff"Tb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3bondTb4b4b4(Te6edf3deviceTb4b4b4)
Te6edf3scannerTb4b4b4.Te6edf3emitDeviceTb4b4b4(Te6edf3deviceTb4b4b4)

Te6edf3every Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4, Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4} Te6edf3returns Tffa657Unit

Tff7b72val Te6edf3bleTransport Tff7b72=
Te6edf3BleRadioTransportTb4b4b4(
Te6edf3scope Tff7b72= Tff7b72thisTb4b4b4,
Te6edf3scanner Tff7b72= Te6edf3scannerTb4b4b4,
Te6edf3bluetoothRepository Tff7b72= Te6edf3bluetoothRepositoryTb4b4b4,
Te6edf3connectionFactory Tff7b72= Te6edf3connectionFactoryTb4b4b4,
Te6edf3callback Tff7b72= Te6edf3serviceTb4b4b4,
Te6edf3address Tff7b72= Te6edf3addressTb4b4b4,
Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3startTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff4Te6edf3_000LTb4b4b4)

T8b949e// Inject CancellationException into a write
Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3writeException Tff7b72= Te6edf3CancellationExceptionTb4b4b4(Ta5d6ff"Ta5d6ffcancelledTa5d6ff"Tb4b4b4)
Te6edf3bleTransportTb4b4b4.Te6edf3handleSendToRadioTb4b4b4(Te6edf3byteArrayOfTb4b4b4(T79c0ff1Tb4b4b4)Tb4b4b4)
T8b949e// Drain the cancelled write coroutine; no retry is triggered for CancellationException.
Te6edf3testSchedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)

T8b949e// CancellationException must NOT result in a user-facing disconnect callback
Te6edf3devTb4b4b4.Te6edf3mokkeryTb4b4b4.Te6edf3verifyTb4b4b4(Te6edf3mode Tff7b72= Te6edf3devTb4b4b4.Te6edf3mokkeryTb4b4b4.Te6edf3verifyTb4b4b4.Te6edf3VerifyModeTb4b4b4.Te6edf3notTb4b4b4) Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4, Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4}

T8b949e// Clear the exception and verify the transport is still usable
Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3writeException Tff7b72= Tff7b72null
Te6edf3bleTransportTb4b4b4.Te6edf3handleSendToRadioTb4b4b4(Te6edf3byteArrayOfTb4b4b4(T79c0ff2Tb4b4b4)Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

T8b949e// Still no disconnect should have been called
Te6edf3devTb4b4b4.Te6edf3mokkeryTb4b4b4.Te6edf3verifyTb4b4b4(Te6edf3mode Tff7b72= Te6edf3devTb4b4b4.Te6edf3mokkeryTb4b4b4.Te6edf3verifyTb4b4b4.Te6edf3VerifyModeTb4b4b4.Te6edf3notTb4b4b4) Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4, Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4}

Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e// ─── Read-path session-failure recovery (fromRadio fatal) ──────────────────────────────────────

T8b949e/**
* Validates that a session-fatal exception in the fromRadio READ path (not just the write path) triggers
* handleFailure β†’ forced disconnect β†’ reconnect.
*
* Existing tests only exercise the WRITE path. The read-path recovery is the primary mechanism for detecting zombie
* sessions where fromRadio throws GATT 133/19/8/129 during a poll.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`fromRadio read failure triggers handleFailure and reconnect`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTest Tb4b4b4{
Tff7b72val Te6edf3device Tff7b72= Te6edf3FakeBleDeviceTb4b4b4(Te6edf3address Tff7b72= Te6edf3addressTb4b4b4, Te6edf3name Tff7b72= Ta5d6ff"Ta5d6ffTest RadioTa5d6ff"Tb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3bondTb4b4b4(Te6edf3deviceTb4b4b4)
Te6edf3scannerTb4b4b4.Te6edf3emitDeviceTb4b4b4(Te6edf3deviceTb4b4b4)

T8b949e// Register FROMNUM + FROMRADIO before start() so the profile's observe collector and read
T8b949e// loop are set up during discoverServicesAndSetupCharacteristics. Without FROMNUM, no
T8b949e// notification collector is established (emitNotification fires into a void). Without
T8b949e// FROMRADIO, hasCharacteristic(fromRadioChar) returns false and the read loop skips before
T8b949e// readException can fire.
Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3addCharacteristicTb4b4b4(Te6edf3FROMNUM_CHARACTERISTICTb4b4b4)
Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3addCharacteristicTb4b4b4(Te6edf3FROMRADIO_CHARACTERISTICTb4b4b4)

Tff7b72val Te6edf3bleTransport Tff7b72=
Te6edf3BleRadioTransportTb4b4b4(
Te6edf3scope Tff7b72= Tff7b72thisTb4b4b4,
Te6edf3scanner Tff7b72= Te6edf3scannerTb4b4b4,
Te6edf3bluetoothRepository Tff7b72= Te6edf3bluetoothRepositoryTb4b4b4,
Te6edf3connectionFactory Tff7b72= Te6edf3connectionFactoryTb4b4b4,
Te6edf3callback Tff7b72= Te6edf3serviceTb4b4b4,
Te6edf3address Tff7b72= Te6edf3addressTb4b4b4,
Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3startTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff4Te6edf3_000LTb4b4b4) T8b949e// connect + handshake
Te6edf3assertTrueTb4b4b4(Te6edf3connectionTb4b4b4.Te6edf3connectAndAwaitCalls Tff7b72=Tff7b72= T79c0ff1Tb4b4b4, Ta5d6ff"Ta5d6ffFirst connect must happenTa5d6ff"Tb4b4b4)

T8b949e// Inject a read failure that will fire on the next drain cycle
Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3readException Tff7b72= Te6edf3NotConnectedExceptionTb4b4b4(Ta5d6ff"Ta5d6ffread session closedTa5d6ff"Tb4b4b4)

T8b949e// Trigger a drain by emitting a FROMNUM notification β€” this causes the profile to poll
T8b949e// fromRadioChar, which throws NotConnectedException (a session-fatal BLE exception).
Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3emitNotificationTb4b4b4(Te6edf3FROMNUM_CHARACTERISTICTb4b4b4, Te6edf3byteArrayOfTb4b4b4(T79c0ff1Tb4b4b4)Tb4b4b4)
T8b949e// Drain the immediate fromRadio failure and forced disconnect; the explicit advanceTimeBy
T8b949e// below covers the reconnect loop's delayed retry.
Te6edf3testSchedulerTb4b4b4.Te6edf3runCurrentTb4b4b4(Tb4b4b4)

T8b949e// handleFailure must have forced a GATT disconnect
Te6edf3assertTrueTb4b4b4(Te6edf3connectionTb4b4b4.Te6edf3disconnectCalls Tff7b72>Tff7b72= T79c0ff1Tb4b4b4, Ta5d6ff"Ta5d6ffdisconnect() must be called after fromRadio read failureTa5d6ff"Tb4b4b4)

T8b949e// Reconnect policy should iterate
Te6edf3advanceTimeByTb4b4b4(T79c0ff1T79c0ff0Te6edf3_000LTb4b4b4)
Te6edf3assertTrueTb4b4b4(
Te6edf3connectionTb4b4b4.Te6edf3connectAndAwaitCalls Tff7b72>Tff7b72= T79c0ff2Tb4b4b4,
Ta5d6ff"Ta5d6ffReconnect loop must iterate after fromRadio read failure Ta5d6ff" Tff7b72+
Ta5d6ff"Ta5d6ff(actual calls: Tffd700${Te6edf3connectionTb4b4b4.Te6edf3connectAndAwaitCallsTffd700}Ta5d6ff)Ta5d6ff"Tb4b4b4,
Tb4b4b4)

Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Validates the sessionFailed dedup guard when read-path and write-path failures race.
*
* The guard at the top of handleFailure exists precisely for this scenario: fromRadio's .catch handler and a
* concurrent write failure both call handleFailure against the same dead session. Only the first caller should fire
* onDisconnect.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`concurrent fromRadio and write failure fires onDisconnect exactly once`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTest Tb4b4b4{
Tff7b72val Te6edf3device Tff7b72= Te6edf3FakeBleDeviceTb4b4b4(Te6edf3address Tff7b72= Te6edf3addressTb4b4b4, Te6edf3name Tff7b72= Ta5d6ff"Ta5d6ffTest RadioTa5d6ff"Tb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3bondTb4b4b4(Te6edf3deviceTb4b4b4)
Te6edf3scannerTb4b4b4.Te6edf3emitDeviceTb4b4b4(Te6edf3deviceTb4b4b4)

T8b949e// Register FROMNUM + FROMRADIO before start() so both read and write failure paths can
T8b949e// fire. See the read-failure test above for details.
Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3addCharacteristicTb4b4b4(Te6edf3FROMNUM_CHARACTERISTICTb4b4b4)
Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3addCharacteristicTb4b4b4(Te6edf3FROMRADIO_CHARACTERISTICTb4b4b4)

Tff7b72var Te6edf3onDisconnectCalls Tff7b72= T79c0ff0
Te6edf3every Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4, Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4} Te6edf3calls Tb4b4b4{ Te6edf3onDisconnectCallsTff7b72+Tff7b72+ Tb4b4b4}

Tff7b72val Te6edf3bleTransport Tff7b72=
Te6edf3BleRadioTransportTb4b4b4(
Te6edf3scope Tff7b72= Tff7b72thisTb4b4b4,
Te6edf3scanner Tff7b72= Te6edf3scannerTb4b4b4,
Te6edf3bluetoothRepository Tff7b72= Te6edf3bluetoothRepositoryTb4b4b4,
Te6edf3connectionFactory Tff7b72= Te6edf3connectionFactoryTb4b4b4,
Te6edf3callback Tff7b72= Te6edf3serviceTb4b4b4,
Te6edf3address Tff7b72= Te6edf3addressTb4b4b4,
Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3startTb4b4b4(Tb4b4b4)
Te6edf3advanceTimeByTb4b4b4(T79c0ff4Te6edf3_000LTb4b4b4)

T8b949e// Inject BOTH read and write failures against the same session
Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3readException Tff7b72= Te6edf3NotConnectedExceptionTb4b4b4(Ta5d6ff"Ta5d6ffread failureTa5d6ff"Tb4b4b4)
Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3writeException Tff7b72= Te6edf3NotConnectedExceptionTb4b4b4(Ta5d6ff"Ta5d6ffwrite failureTa5d6ff"Tb4b4b4)

T8b949e// Trigger both paths nearly simultaneously
Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3emitNotificationTb4b4b4(Te6edf3FROMNUM_CHARACTERISTICTb4b4b4, Te6edf3byteArrayOfTb4b4b4(T79c0ff1Tb4b4b4)Tb4b4b4)
Te6edf3bleTransportTb4b4b4.Te6edf3handleSendToRadioTb4b4b4(Te6edf3byteArrayOfTb4b4b4(T79c0ff4T79c0ff2Tb4b4b4)Tb4b4b4)
T8b949e// Advance through the write-path retryBleOperation (~750ms) so BOTH the read and write
T8b949e// failure paths reach handleFailure, truly exercising the CAS dedup guard.
Te6edf3advanceTimeByTb4b4b4(T79c0ff1Te6edf3_000LTb4b4b4)

Te6edf3assertEqualsTb4b4b4(
T79c0ff1Tb4b4b4,
Te6edf3onDisconnectCallsTb4b4b4,
Ta5d6ff"Ta5d6ffonDisconnect must fire exactly once despite concurrent read+write failures Ta5d6ff" Tff7b72+
Ta5d6ff"Ta5d6ff(actual: Tffd700$Te6edf3onDisconnectCallsTa5d6ff)Ta5d6ff"Tb4b4b4,
Tb4b4b4)

Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e// ─── FROMNUM subscription readiness setup failures ───────────────────────────────────────────

Tf0883e@Test
Tff7b72fun Td2a8ff`FROMNUM NotConnected before readiness aborts setup and retries`Tb4b4b4(Tb4b4b4) Tff7b72=
Te6edf3fromNumPreReadinessFailureAbortsSetupAndRetriesTb4b4b4(
Te6edf3failure Tff7b72= Te6edf3NotConnectedExceptionTb4b4b4(Ta5d6ff"Ta5d6ffFROMNUM observe failed before CCCDTa5d6ff"Tb4b4b4)Tb4b4b4,
Tb4b4b4)

Tf0883e@Test
Tff7b72fun Td2a8ff`FROMNUM GATT 133 before readiness aborts setup and retries`Tb4b4b4(Tb4b4b4) Tff7b72=
Te6edf3fromNumPreReadinessFailureAbortsSetupAndRetriesTb4b4b4(
Te6edf3failure Tff7b72= Te6edf3GattStatusExceptionTb4b4b4(Te6edf3status Tff7b72= T79c0ff1T79c0ff3T79c0ff3Tb4b4b4, Te6edf3message Tff7b72= Ta5d6ff"Ta5d6ffGATT errorTa5d6ff"Tb4b4b4)Tb4b4b4,
Tb4b4b4)

Tff7b72private Tff7b72fun Td2a8fffromNumPreReadinessFailureAbortsSetupAndRetriesTb4b4b4(Te6edf3failureTb4b4b4: Te6edf3ExceptionTb4b4b4) Tff7b72= Te6edf3runTest Tb4b4b4{
Tff7b72val Te6edf3device Tff7b72= Te6edf3FakeBleDeviceTb4b4b4(Te6edf3address Tff7b72= Te6edf3addressTb4b4b4, Te6edf3name Tff7b72= Ta5d6ff"Ta5d6ffTest RadioTa5d6ff"Tb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3bondTb4b4b4(Te6edf3deviceTb4b4b4)
Te6edf3scannerTb4b4b4.Te6edf3emitDeviceTb4b4b4(Te6edf3deviceTb4b4b4)

Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3addCharacteristicTb4b4b4(Te6edf3FROMNUM_CHARACTERISTICTb4b4b4)
Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3observeBeforeSubscriptionExceptionByCharacteristicTff7b72[Te6edf3FROMNUM_CHARACTERISTICTff7b72] Tff7b72= Te6edf3failure

Tff7b72var Te6edf3onConnectCalls Tff7b72= T79c0ff0
Te6edf3every Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3onConnectTb4b4b4(Tb4b4b4) Tb4b4b4} Te6edf3calls Tb4b4b4{ Te6edf3onConnectCallsTff7b72+Tff7b72+ Tb4b4b4}
Te6edf3every Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4, Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4} Te6edf3returns Tffa657Unit

Tff7b72val Te6edf3bleTransport Tff7b72=
Te6edf3BleRadioTransportTb4b4b4(
Te6edf3scope Tff7b72= Tff7b72thisTb4b4b4,
Te6edf3scanner Tff7b72= Te6edf3scannerTb4b4b4,
Te6edf3bluetoothRepository Tff7b72= Te6edf3bluetoothRepositoryTb4b4b4,
Te6edf3connectionFactory Tff7b72= Te6edf3connectionFactoryTb4b4b4,
Te6edf3callback Tff7b72= Te6edf3serviceTb4b4b4,
Te6edf3address Tff7b72= Te6edf3addressTb4b4b4,
Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3startTb4b4b4(Tb4b4b4)

T8b949e// Initial settle (3s) + setup failure. Assert before retry can succeed, proving the failed
T8b949e// pre-readiness attempt did not falsely mark subscription readiness or call onConnect().
Te6edf3advanceTimeByTb4b4b4(T79c0ff4Te6edf3_000LTb4b4b4)

Te6edf3assertTrueTb4b4b4(Te6edf3connectionTb4b4b4.Te6edf3disconnectCalls Tff7b72>Tff7b72= T79c0ff1Tb4b4b4, Ta5d6ff"Ta5d6ffdisconnect() must be called after FROMNUM observe failureTa5d6ff"Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(T79c0ff0Tb4b4b4, Te6edf3onConnectCallsTb4b4b4, Ta5d6ff"Ta5d6ffFailed pre-readiness setup must not call onConnectTa5d6ff"Tb4b4b4)

Te6edf3advanceTimeByTb4b4b4(T79c0ff1T79c0ff0Te6edf3_000LTb4b4b4)
Te6edf3assertTrueTb4b4b4(
Te6edf3connectionTb4b4b4.Te6edf3connectAndAwaitCalls Tff7b72>Tff7b72= T79c0ff2Tb4b4b4,
Ta5d6ff"Ta5d6ffReconnect loop must retry after FROMNUM pre-readiness failure Ta5d6ff" Tff7b72+
Ta5d6ff"Ta5d6ff(actual calls: Tffd700${Te6edf3connectionTb4b4b4.Te6edf3connectAndAwaitCallsTffd700}Ta5d6ff)Ta5d6ff"Tb4b4b4,
Tb4b4b4)

Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

Tf0883e@Test
Tff7b72fun Td2a8ff`FROMNUM subscription readiness timeout aborts setup clears stale service and retries`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTest Tb4b4b4{
Tff7b72val Te6edf3device Tff7b72= Te6edf3FakeBleDeviceTb4b4b4(Te6edf3address Tff7b72= Te6edf3addressTb4b4b4, Te6edf3name Tff7b72= Ta5d6ff"Ta5d6ffTest RadioTa5d6ff"Tb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3bondTb4b4b4(Te6edf3deviceTb4b4b4)
Te6edf3scannerTb4b4b4.Te6edf3emitDeviceTb4b4b4(Te6edf3deviceTb4b4b4)

Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3addCharacteristicTb4b4b4(Te6edf3FROMNUM_CHARACTERISTICTb4b4b4)
Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3observeNeverSubscribeCharacteristics Tff7b72+Tff7b72= Te6edf3FROMNUM_CHARACTERISTIC

Tff7b72var Te6edf3onConnectCalls Tff7b72= T79c0ff0
Te6edf3every Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3onConnectTb4b4b4(Tb4b4b4) Tb4b4b4} Te6edf3calls Tb4b4b4{ Te6edf3onConnectCallsTff7b72+Tff7b72+ Tb4b4b4}
Te6edf3every Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4, Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4} Te6edf3returns Tffa657Unit

Tff7b72val Te6edf3bleTransport Tff7b72=
Te6edf3BleRadioTransportTb4b4b4(
Te6edf3scope Tff7b72= Tff7b72thisTb4b4b4,
Te6edf3scanner Tff7b72= Te6edf3scannerTb4b4b4,
Te6edf3bluetoothRepository Tff7b72= Te6edf3bluetoothRepositoryTb4b4b4,
Te6edf3connectionFactory Tff7b72= Te6edf3connectionFactoryTb4b4b4,
Te6edf3callback Tff7b72= Te6edf3serviceTb4b4b4,
Te6edf3address Tff7b72= Te6edf3addressTb4b4b4,
Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3startTb4b4b4(Tb4b4b4)

T8b949e// Settle (3s) + SUBSCRIPTION_READY_TIMEOUT (5s) + margin. FROMNUM observe never invokes
T8b949e// onSubscription, so setup must abort instead of continuing with a half-initialized service.
Te6edf3advanceTimeByTb4b4b4(T79c0ff9Te6edf3_000LTb4b4b4)

Te6edf3assertTrueTb4b4b4(Te6edf3connectionTb4b4b4.Te6edf3disconnectCalls Tff7b72>Tff7b72= T79c0ff1Tb4b4b4, Ta5d6ff"Ta5d6ffdisconnect() must be called after subscription timeoutTa5d6ff"Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(T79c0ff0Tb4b4b4, Te6edf3onConnectCallsTb4b4b4, Ta5d6ff"Ta5d6ffSubscription timeout must not call onConnectTa5d6ff"Tb4b4b4)

Tff7b72val Te6edf3writesBefore Tff7b72= Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3writesTb4b4b4.Te6edf3size
Te6edf3bleTransportTb4b4b4.Te6edf3handleSendToRadioTb4b4b4(Te6edf3byteArrayOfTb4b4b4(T79c0ff7Tb4b4b4, T79c0ff8Tb4b4b4, T79c0ff9Tb4b4b4)Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(
Te6edf3writesBeforeTb4b4b4,
Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3writesTb4b4b4.Te6edf3sizeTb4b4b4,
Ta5d6ff"Ta5d6ffTimed-out setup must clear radioService so writes do not use a half-initialized serviceTa5d6ff"Tb4b4b4,
Tb4b4b4)

Te6edf3advanceTimeByTb4b4b4(T79c0ff1T79c0ff0Te6edf3_000LTb4b4b4)
Te6edf3assertTrueTb4b4b4(
Te6edf3connectionTb4b4b4.Te6edf3connectAndAwaitCalls Tff7b72>Tff7b72= T79c0ff2Tb4b4b4,
Ta5d6ff"Ta5d6ffReconnect loop must retry after subscription readiness timeout Ta5d6ff" Tff7b72+
Ta5d6ff"Ta5d6ff(actual calls: Tffd700${Te6edf3connectionTb4b4b4.Te6edf3connectAndAwaitCallsTffd700}Ta5d6ff)Ta5d6ff"Tb4b4b4,
Tb4b4b4)

Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e// ─── Connected-gate timeout cleanup ──────────────────────────────────────────────────────────

Tf0883e@Test
Tff7b72fun Td2a8ff`Connected-gate timeout cleans up stale service and retries without disconnect spam`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTest Tb4b4b4{
Tff7b72val Te6edf3staleConnection Tff7b72= Te6edf3NeverConnectedStateBleConnectionTb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3staleFactory Tff7b72=
Tff7b72object Tb4b4b4: T56d364BleConnectionFactory Tb4b4b4{
Tff7b72override Tff7b72fun Td2a8ffcreateTb4b4b4(Te6edf3scopeTb4b4b4: Te6edf3CoroutineScopeTb4b4b4, Te6edf3tagTb4b4b4: Tffa657StringTb4b4b4)Tb4b4b4: Te6edf3BleConnection Tff7b72= Te6edf3staleConnection
Tb4b4b4}
Tff7b72val Te6edf3device Tff7b72= Te6edf3FakeBleDeviceTb4b4b4(Te6edf3address Tff7b72= Te6edf3addressTb4b4b4, Te6edf3name Tff7b72= Ta5d6ff"Ta5d6ffTest RadioTa5d6ff"Tb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3bondTb4b4b4(Te6edf3deviceTb4b4b4)
Te6edf3scannerTb4b4b4.Te6edf3emitDeviceTb4b4b4(Te6edf3deviceTb4b4b4)

Tff7b72var Te6edf3onDisconnectCalls Tff7b72= T79c0ff0
Te6edf3every Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4, Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4} Te6edf3calls Tb4b4b4{ Te6edf3onDisconnectCallsTff7b72+Tff7b72+ Tb4b4b4}

Tff7b72val Te6edf3bleTransport Tff7b72=
Te6edf3BleRadioTransportTb4b4b4(
Te6edf3scope Tff7b72= Tff7b72thisTb4b4b4,
Te6edf3scanner Tff7b72= Te6edf3scannerTb4b4b4,
Te6edf3bluetoothRepository Tff7b72= Te6edf3bluetoothRepositoryTb4b4b4,
Te6edf3connectionFactory Tff7b72= Te6edf3staleFactoryTb4b4b4,
Te6edf3callback Tff7b72= Te6edf3serviceTb4b4b4,
Te6edf3address Tff7b72= Te6edf3addressTb4b4b4,
Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3startTb4b4b4(Tb4b4b4)

T8b949e// Settle (3s) + CONNECTED_GATE_TIMEOUT (5s) + margin. connectAndAwait returns Connected,
T8b949e// profile setup succeeds, but connectionState never emits Connected, so the gate times out.
Te6edf3advanceTimeByTb4b4b4(T79c0ff9Te6edf3_000LTb4b4b4)

Te6edf3assertTrueTb4b4b4(Te6edf3staleConnectionTb4b4b4.Te6edf3disconnectCalls Tff7b72>Tff7b72= T79c0ff1Tb4b4b4, Ta5d6ff"Ta5d6ffConnected-gate timeout must force GATT disconnectTa5d6ff"Tb4b4b4)
Te6edf3assertTrueTb4b4b4(Te6edf3onDisconnectCalls Tff7b72<Tff7b72= T79c0ff1Tb4b4b4, Ta5d6ff"Ta5d6ffConnected-gate timeout must not spam onDisconnect callbacksTa5d6ff"Tb4b4b4)

Tff7b72val Te6edf3writesBefore Tff7b72= Te6edf3staleConnectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3writesTb4b4b4.Te6edf3size
Te6edf3bleTransportTb4b4b4.Te6edf3handleSendToRadioTb4b4b4(Te6edf3byteArrayOfTb4b4b4(T79c0ff4Tb4b4b4, T79c0ff5Tb4b4b4, T79c0ff6Tb4b4b4)Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(
Te6edf3writesBeforeTb4b4b4,
Te6edf3staleConnectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3writesTb4b4b4.Te6edf3sizeTb4b4b4,
Ta5d6ff"Ta5d6ffConnected-gate timeout must clear radioService so writes do not use stale profile stateTa5d6ff"Tb4b4b4,
Tb4b4b4)

Te6edf3advanceTimeByTb4b4b4(T79c0ff1T79c0ff0Te6edf3_000LTb4b4b4)
Te6edf3assertTrueTb4b4b4(
Te6edf3staleConnectionTb4b4b4.Te6edf3connectAndAwaitCalls Tff7b72>Tff7b72= T79c0ff2Tb4b4b4,
Ta5d6ff"Ta5d6ffReconnect loop must retry after Connected-gate timeout Ta5d6ff" Tff7b72+
Ta5d6ff"Ta5d6ff(actual calls: Tffd700${Te6edf3staleConnectionTb4b4b4.Te6edf3connectAndAwaitCallsTffd700}Ta5d6ff)Ta5d6ff"Tb4b4b4,
Tb4b4b4)

Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Validates the normal Connected-gate path: when connectAndAwait returns Connected and the connectionState
* StateFlow reflects Connected, the gate succeeds immediately and the transport operates normally. The timeout path
* β€” where connectionState never reaches Connected β€” is covered separately by the `Connected-gate timeout cleans up
* stale service and retries without disconnect spam` test.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`Connected-gate normal path succeeds and transport operates`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTest Tb4b4b4{
Tff7b72val Te6edf3device Tff7b72= Te6edf3FakeBleDeviceTb4b4b4(Te6edf3address Tff7b72= Te6edf3addressTb4b4b4, Te6edf3name Tff7b72= Ta5d6ff"Ta5d6ffTest RadioTa5d6ff"Tb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3bondTb4b4b4(Te6edf3deviceTb4b4b4)
Te6edf3scannerTb4b4b4.Te6edf3emitDeviceTb4b4b4(Te6edf3deviceTb4b4b4)

Tff7b72val Te6edf3bleTransport Tff7b72=
Te6edf3BleRadioTransportTb4b4b4(
Te6edf3scope Tff7b72= Tff7b72thisTb4b4b4,
Te6edf3scanner Tff7b72= Te6edf3scannerTb4b4b4,
Te6edf3bluetoothRepository Tff7b72= Te6edf3bluetoothRepositoryTb4b4b4,
Te6edf3connectionFactory Tff7b72= Te6edf3connectionFactoryTb4b4b4,
Te6edf3callback Tff7b72= Te6edf3serviceTb4b4b4,
Te6edf3address Tff7b72= Te6edf3addressTb4b4b4,
Tb4b4b4)
Te6edf3every Tb4b4b4{ Te6edf3serviceTb4b4b4.Te6edf3onDisconnectTb4b4b4(Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4, Te6edf3anyTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4} Te6edf3returns Tffa657Unit

Tff7b72try Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3startTb4b4b4(Tb4b4b4)
T8b949e// advance past connect β€” connectAndAwait returns Connected synchronously and the
T8b949e// FakeBleConnection sets connectionState to Connected in connect(), so this test
T8b949e// verifies the normal path where the gate succeeds.
Te6edf3advanceTimeByTb4b4b4(T79c0ff1T79c0ff0Te6edf3_000LTb4b4b4)
Te6edf3assertTrueTb4b4b4(Te6edf3connectionTb4b4b4.Te6edf3connectAndAwaitCalls Tff7b72>Tff7b72= T79c0ff1Tb4b4b4, Ta5d6ff"Ta5d6ffMust attempt at least one connectionTa5d6ff"Tb4b4b4)

Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e// ─── Bonded fresh-advertisement discovery ─────────────────────────────────────────────────────

T8b949e/**
* Regression: bonded reconnect uses one bounded scanner registration and still prefers a fresh advertisement over
* the stale bonded handle. Starting a short scan and then a second escalated scan consumed two Android scan starts
* per reconnect, which could trigger SCAN_FAILED_SCANNING_TOO_FREQUENTLY during rapid device switching.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`bonded reconnect uses one bounded scan and prefers its fresh device`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTest Tb4b4b4{
Tff7b72val Te6edf3bondedDevice Tff7b72= Te6edf3FakeBleDeviceTb4b4b4(Te6edf3address Tff7b72= Te6edf3addressTb4b4b4, Te6edf3name Tff7b72= Ta5d6ff"Ta5d6ffBonded HandleTa5d6ff"Tb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3bondTb4b4b4(Te6edf3bondedDeviceTb4b4b4)

Tff7b72val Te6edf3freshDevice Tff7b72= Te6edf3FakeBleDeviceTb4b4b4(Te6edf3address Tff7b72= Te6edf3addressTb4b4b4, Te6edf3name Tff7b72= Ta5d6ff"Ta5d6ffFresh Scan ResultTa5d6ff"Tb4b4b4)
Tff7b72val Te6edf3sequencedScanner Tff7b72= Te6edf3SequencedBleScannerTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3apply Tb4b4b4{ Te6edf3responses Tff7b72+Tff7b72= Tb4b4b4{ Te6edf3flow Tb4b4b4{ Te6edf3emitTb4b4b4(Te6edf3freshDeviceTb4b4b4) Tb4b4b4} Tb4b4b4} Tb4b4b4}

T8b949e// Profile setup needs FROMNUM/FROMRADIO so the transport reaches a stable Connected state
T8b949e// rather than tearing down before we can inspect which device was connected.
Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3addCharacteristicTb4b4b4(Te6edf3FROMNUM_CHARACTERISTICTb4b4b4)
Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3addCharacteristicTb4b4b4(Te6edf3FROMRADIO_CHARACTERISTICTb4b4b4)

Tff7b72val Te6edf3bleTransport Tff7b72=
Te6edf3BleRadioTransportTb4b4b4(
Te6edf3scope Tff7b72= Tff7b72thisTb4b4b4,
Te6edf3scanner Tff7b72= Te6edf3sequencedScannerTb4b4b4,
Te6edf3bluetoothRepository Tff7b72= Te6edf3bluetoothRepositoryTb4b4b4,
Te6edf3connectionFactory Tff7b72= Te6edf3connectionFactoryTb4b4b4,
Te6edf3callback Tff7b72= Te6edf3serviceTb4b4b4,
Te6edf3address Tff7b72= Te6edf3addressTb4b4b4,
Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3startTb4b4b4(Tb4b4b4)

Te6edf3advanceTimeByTb4b4b4(Te6edf3bondedReconnectWindowMillisTb4b4b4)

Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3sequencedScannerTb4b4b4.Te6edf3callCountTb4b4b4, Ta5d6ff"Ta5d6ffBonded reconnect must consume one scan registrationTa5d6ff"Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(
Te6edf3bondedScanTimeoutMillisTb4b4b4,
Te6edf3sequencedScannerTb4b4b4.Te6edf3callsTb4b4b4.Te6edf3singleTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3timeoutTb4b4b4.Te6edf3inWholeMillisecondsTb4b4b4,
Ta5d6ff"Ta5d6ffThe single bonded scan must use SCAN_TIMEOUTTa5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(Te6edf3SERVICE_UUIDTb4b4b4, Te6edf3sequencedScannerTb4b4b4.Te6edf3callsTb4b4b4.Te6edf3singleTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3serviceUuidTb4b4b4, Ta5d6ff"Ta5d6ffScan must include service UUIDTa5d6ff"Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(Te6edf3addressTb4b4b4, Te6edf3sequencedScannerTb4b4b4.Te6edf3callsTb4b4b4.Te6edf3singleTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3addressTb4b4b4, Ta5d6ff"Ta5d6ffScan must target selected addressTa5d6ff"Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3connectionTb4b4b4.Te6edf3connectAndAwaitCallsTb4b4b4, Ta5d6ff"Ta5d6ffMust connect exactly once with the fresh scanned deviceTa5d6ff"Tb4b4b4)
Te6edf3assertTrueTb4b4b4(
Te6edf3connectionTb4b4b4.Te6edf3device Tff7b72=Tff7b72=Tff7b72= Te6edf3freshDeviceTb4b4b4,
Ta5d6ff"Ta5d6ffMust use the fresh scanned device, not the stale bonded handle Ta5d6ff" Tff7b72+
Ta5d6ff"Ta5d6ff(got: Tffd700${Te6edf3connectionTb4b4b4.Te6edf3deviceTff7b72?.Te6edf3nameTffd700}Ta5d6ff, expected: Tffd700${Te6edf3freshDeviceTb4b4b4.Te6edf3nameTffd700}Ta5d6ff)Ta5d6ff"Tb4b4b4,
Tb4b4b4)

Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e/**
* Regression: when the bonded device misses the bounded fresh-advertisement scan, [findDevice] must fall back to
* the bonded handle so Android's autoConnect path can patiently wait for the radio to advertise again.
*/
Tf0883e@Test
Tff7b72fun Td2a8ff`bonded device never advertising falls back after one bounded scan`Tb4b4b4(Tb4b4b4) Tff7b72= Te6edf3runTest Tb4b4b4{
Tff7b72val Te6edf3bondedDevice Tff7b72= Te6edf3FakeBleDeviceTb4b4b4(Te6edf3address Tff7b72= Te6edf3addressTb4b4b4, Te6edf3name Tff7b72= Ta5d6ff"Ta5d6ffBonded HandleTa5d6ff"Tb4b4b4)
Te6edf3bluetoothRepositoryTb4b4b4.Te6edf3bondTb4b4b4(Te6edf3bondedDeviceTb4b4b4)

Tff7b72val Te6edf3sequencedScanner Tff7b72= Te6edf3SequencedBleScannerTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3apply Tb4b4b4{ Te6edf3responses Tff7b72+Tff7b72= Tb4b4b4{ Te6edf3flow Tb4b4b4{ Te6edf3awaitCancellationTb4b4b4(Tb4b4b4) Tb4b4b4} Tb4b4b4} Tb4b4b4}

T8b949e// Keep profile setup stable so the test can inspect the connected device.
Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3addCharacteristicTb4b4b4(Te6edf3FROMNUM_CHARACTERISTICTb4b4b4)
Te6edf3connectionTb4b4b4.Te6edf3serviceTb4b4b4.Te6edf3addCharacteristicTb4b4b4(Te6edf3FROMRADIO_CHARACTERISTICTb4b4b4)

Tff7b72val Te6edf3bleTransport Tff7b72=
Te6edf3BleRadioTransportTb4b4b4(
Te6edf3scope Tff7b72= Tff7b72thisTb4b4b4,
Te6edf3scanner Tff7b72= Te6edf3sequencedScannerTb4b4b4,
Te6edf3bluetoothRepository Tff7b72= Te6edf3bluetoothRepositoryTb4b4b4,
Te6edf3connectionFactory Tff7b72= Te6edf3connectionFactoryTb4b4b4,
Te6edf3callback Tff7b72= Te6edf3serviceTb4b4b4,
Te6edf3address Tff7b72= Te6edf3addressTb4b4b4,
Tb4b4b4)
Tff7b72try Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3startTb4b4b4(Tb4b4b4)

Te6edf3advanceTimeByTb4b4b4(Te6edf3bondedReconnectWindowMillisTb4b4b4)

Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3sequencedScannerTb4b4b4.Te6edf3callCountTb4b4b4, Ta5d6ff"Ta5d6ffBonded fallback must consume one scan registrationTa5d6ff"Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(
Te6edf3bondedScanTimeoutMillisTb4b4b4,
Te6edf3sequencedScannerTb4b4b4.Te6edf3callsTb4b4b4.Te6edf3singleTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3timeoutTb4b4b4.Te6edf3inWholeMillisecondsTb4b4b4,
Ta5d6ff"Ta5d6ffThe single bonded scan must use SCAN_TIMEOUTTa5d6ff"Tb4b4b4,
Tb4b4b4)
Te6edf3assertEqualsTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3connectionTb4b4b4.Te6edf3connectAndAwaitCallsTb4b4b4, Ta5d6ff"Ta5d6ffMust connect once through the bounded bonded fallbackTa5d6ff"Tb4b4b4)
Te6edf3assertTrueTb4b4b4(
Te6edf3connectionTb4b4b4.Te6edf3device Tff7b72=Tff7b72=Tff7b72= Te6edf3bondedDeviceTb4b4b4,
Ta5d6ff"Ta5d6ffMust use the bonded handle after the fresh scan misses (got: Tffd700${Te6edf3connectionTb4b4b4.Te6edf3deviceTff7b72?.Te6edf3nameTffd700}Ta5d6ff)Ta5d6ff"Tb4b4b4,
Tb4b4b4)

Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72finally Tb4b4b4{
Te6edf3bleTransportTb4b4b4.Te6edf3closeTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}
Tb4b4b4}

T8b949e// ─── Test doubles ────────────────────────────────────────────────────────────────────────────────

T8b949e/**
* A [BleConnection] that succeeds at [connectAndAwait] but throws [CancellationException] from [profile]. This
* simulates what happens when the owning coroutine scope is cancelled while GATT service discovery is in progress.
*/
Tff7b72private Tff7b72class T56d364CancellingProfileBleConnection Tb4b4b4: Te6edf3BleConnection Tb4b4b4{

Tff7b72private Tff7b72val Te6edf3_deviceFlow Tff7b72= Te6edf3MutableStateFlowTff7b72<Te6edf3BleDevice?Tff7b72>Tb4b4b4(Tff7b72nullTb4b4b4)
Tff7b72override Tff7b72val Te6edf3deviceFlowTb4b4b4: Te6edf3StateFlowTff7b72<Te6edf3BleDevice?Tff7b72> Tff7b72= Te6edf3_deviceFlowTb4b4b4.Te6edf3asStateFlowTb4b4b4(Tb4b4b4)

Tff7b72private Tff7b72val Te6edf3_connectionState Tff7b72= Te6edf3MutableStateFlowTff7b72<Te6edf3BleConnectionStateTff7b72>Tb4b4b4(Te6edf3BleConnectionStateTb4b4b4.Te6edf3DisconnectedTb4b4b4(Tb4b4b4)Tb4b4b4)
Tff7b72override Tff7b72val Te6edf3connectionStateTb4b4b4: Te6edf3StateFlowTff7b72<Te6edf3BleConnectionStateTff7b72> Tff7b72= Te6edf3_connectionStateTb4b4b4.Te6edf3asStateFlowTb4b4b4(Tb4b4b4)

Tff7b72override Tff7b72val Te6edf3deviceTb4b4b4: Te6edf3BleDevice? Tff7b72= Tff7b72null

Tff7b72var Te6edf3disconnectCalls Tff7b72= T79c0ff0

Tff7b72override Tff7b72suspend Tff7b72fun Td2a8ffconnectTb4b4b4(Te6edf3deviceTb4b4b4: Te6edf3BleDeviceTb4b4b4) Tb4b4b4{
Te6edf3_deviceFlowTb4b4b4.Te6edf3value Tff7b72= Te6edf3device
Te6edf3_connectionStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3BleConnectionStateTb4b4b4.Te6edf3Connected
Tb4b4b4}

Tff7b72override Tff7b72suspend Tff7b72fun Td2a8ffconnectAndAwaitTb4b4b4(Te6edf3deviceTb4b4b4: Te6edf3BleDeviceTb4b4b4, Te6edf3timeoutTb4b4b4: Te6edf3DurationTb4b4b4)Tb4b4b4: Te6edf3BleConnectionState Tb4b4b4{
Te6edf3connectTb4b4b4(Te6edf3deviceTb4b4b4)
Tff7b72return Te6edf3BleConnectionStateTb4b4b4.Te6edf3Connected
Tb4b4b4}

Tff7b72override Tff7b72suspend Tff7b72fun Td2a8ffdisconnectTb4b4b4(Tb4b4b4) Tb4b4b4{
Te6edf3disconnectCallsTff7b72+Tff7b72+
Te6edf3_connectionStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3BleConnectionStateTb4b4b4.Te6edf3DisconnectedTb4b4b4(Tb4b4b4)
Te6edf3_deviceFlowTb4b4b4.Te6edf3value Tff7b72= Tff7b72null
Tb4b4b4}

Tff7b72override Tff7b72suspend Tff7b72fun Tff7b72<Te6edf3TTff7b72> Td2a8ffprofileTb4b4b4(
Te6edf3serviceUuidTb4b4b4: Te6edf3kotlinTb4b4b4.Te6edf3uuidTb4b4b4.Te6edf3UuidTb4b4b4,
Te6edf3timeoutTb4b4b4: Te6edf3DurationTb4b4b4,
Te6edf3setupTb4b4b4: Te6edf3suspend Te6edf3CoroutineScopeTb4b4b4.Tb4b4b4(Te6edf3BleServiceTb4b4b4) Tff7b72-Tff7b72> Te6edf3TTb4b4b4,
Tb4b4b4)Tb4b4b4: Te6edf3T Tff7b72= Tff7b72throw Te6edf3CancellationExceptionTb4b4b4(Ta5d6ff"Ta5d6ffSimulated scope cancellation during service discoveryTa5d6ff"Tb4b4b4)

Tff7b72override Tff7b72fun Td2a8ffmaximumWriteValueLengthTb4b4b4(Te6edf3writeTypeTb4b4b4: Te6edf3BleWriteTypeTb4b4b4)Tb4b4b4: Tffa657Int? Tff7b72= Tff7b72null
Tb4b4b4}

T8b949e/**
* A [BleConnection] whose [connectAndAwait] reports success while [connectionState] never emits
* [BleConnectionState.Connected]. This exercises the Connected-gate timeout cleanup path after profile setup succeeds.
*/
Tff7b72private Tff7b72class T56d364NeverConnectedStateBleConnection Tb4b4b4: Te6edf3BleConnection Tb4b4b4{

Tff7b72private Tff7b72val Te6edf3_deviceFlow Tff7b72= Te6edf3MutableStateFlowTff7b72<Te6edf3BleDevice?Tff7b72>Tb4b4b4(Tff7b72nullTb4b4b4)
Tff7b72override Tff7b72val Te6edf3deviceFlowTb4b4b4: Te6edf3StateFlowTff7b72<Te6edf3BleDevice?Tff7b72> Tff7b72= Te6edf3_deviceFlowTb4b4b4.Te6edf3asStateFlowTb4b4b4(Tb4b4b4)

Tff7b72private Tff7b72val Te6edf3_connectionState Tff7b72= Te6edf3MutableStateFlowTff7b72<Te6edf3BleConnectionStateTff7b72>Tb4b4b4(Te6edf3BleConnectionStateTb4b4b4.Te6edf3DisconnectedTb4b4b4(Tb4b4b4)Tb4b4b4)
Tff7b72override Tff7b72val Te6edf3connectionStateTb4b4b4: Te6edf3StateFlowTff7b72<Te6edf3BleConnectionStateTff7b72> Tff7b72= Te6edf3_connectionStateTb4b4b4.Te6edf3asStateFlowTb4b4b4(Tb4b4b4)

Tff7b72override Tff7b72val Te6edf3deviceTb4b4b4: Te6edf3BleDevice?
Tff7b72getTb4b4b4(Tb4b4b4) Tff7b72= Te6edf3_deviceFlowTb4b4b4.Te6edf3value

Tff7b72val Te6edf3service Tff7b72= Te6edf3FakeBleServiceTb4b4b4(Tb4b4b4)Tb4b4b4.Te6edf3apply Tb4b4b4{ Te6edf3addCharacteristicTb4b4b4(Te6edf3FROMNUM_CHARACTERISTICTb4b4b4) Tb4b4b4}

Tff7b72var Te6edf3connectAndAwaitCalls Tff7b72= T79c0ff0
Te6edf3private Tff7b72set

Tff7b72var Te6edf3disconnectCalls Tff7b72= T79c0ff0
Te6edf3private Tff7b72set

Tff7b72override Tff7b72suspend Tff7b72fun Td2a8ffconnectTb4b4b4(Te6edf3deviceTb4b4b4: Te6edf3BleDeviceTb4b4b4) Tb4b4b4{
Te6edf3_deviceFlowTb4b4b4.Te6edf3value Tff7b72= Te6edf3device
Te6edf3_connectionStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3BleConnectionStateTb4b4b4.Te6edf3Connecting
Tb4b4b4}

Tff7b72override Tff7b72suspend Tff7b72fun Td2a8ffconnectAndAwaitTb4b4b4(Te6edf3deviceTb4b4b4: Te6edf3BleDeviceTb4b4b4, Te6edf3timeoutTb4b4b4: Te6edf3DurationTb4b4b4)Tb4b4b4: Te6edf3BleConnectionState Tb4b4b4{
Te6edf3connectAndAwaitCallsTff7b72+Tff7b72+
Te6edf3connectTb4b4b4(Te6edf3deviceTb4b4b4)
Tff7b72return Te6edf3BleConnectionStateTb4b4b4.Te6edf3Connected
Tb4b4b4}

Tff7b72override Tff7b72suspend Tff7b72fun Td2a8ffdisconnectTb4b4b4(Tb4b4b4) Tb4b4b4{
Te6edf3disconnectCallsTff7b72+Tff7b72+
Te6edf3_connectionStateTb4b4b4.Te6edf3value Tff7b72= Te6edf3BleConnectionStateTb4b4b4.Te6edf3DisconnectedTb4b4b4(Tb4b4b4)
Te6edf3_deviceFlowTb4b4b4.Te6edf3value Tff7b72= Tff7b72null
Tb4b4b4}

Tff7b72override Tff7b72suspend Tff7b72fun Tff7b72<Te6edf3TTff7b72> Td2a8ffprofileTb4b4b4(
Te6edf3serviceUuidTb4b4b4: Te6edf3kotlinTb4b4b4.Te6edf3uuidTb4b4b4.Te6edf3UuidTb4b4b4,
Te6edf3timeoutTb4b4b4: Te6edf3DurationTb4b4b4,
Te6edf3setupTb4b4b4: Te6edf3suspend Te6edf3CoroutineScopeTb4b4b4.Tb4b4b4(Te6edf3BleServiceTb4b4b4) Tff7b72-Tff7b72> Te6edf3TTb4b4b4,
Tb4b4b4)Tb4b4b4: Te6edf3T Tff7b72= Te6edf3CoroutineScopeTb4b4b4(Te6edf3currentCoroutineContextTb4b4b4(Tb4b4b4)Tb4b4b4)Tb4b4b4.Te6edf3setupTb4b4b4(Te6edf3serviceTb4b4b4)

Tff7b72override Tff7b72fun Td2a8ffmaximumWriteValueLengthTb4b4b4(Te6edf3writeTypeTb4b4b4: Te6edf3BleWriteTypeTb4b4b4)Tb4b4b4: Tffa657Int? Tff7b72= Tff7b72null
Tb4b4b4}

T8b949e/**
* A [BleScanner] test double that records each [scan] call's arguments and delegates to a per-call [Flow] supplier.
*
* Unlike [FakeBleScanner] (which is replay-based and re-emits all previously-emitted devices on every call), this
* scanner lets a test model "first scan misses, second scan finds" by configuring [responses] index-by-index. Calls
* beyond the configured list return a never-completing flow (models a scan that runs until cancelled).
*/
Tff7b72private Tff7b72class T56d364SequencedBleScanner Tb4b4b4: Te6edf3BleScanner Tb4b4b4{
Tff7b72data Tff7b72class T56d364CallTb4b4b4(Tff7b72val Te6edf3timeoutTb4b4b4: Te6edf3DurationTb4b4b4, Tff7b72val Te6edf3serviceUuidTb4b4b4: Te6edf3kotlinTb4b4b4.Te6edf3uuidTb4b4b4.Te6edf3UuidTff7b72?Tb4b4b4, Tff7b72val Te6edf3addressTb4b4b4: Tffa657String?Tb4b4b4)

Tff7b72private Tff7b72val Te6edf3_calls Tff7b72= Te6edf3mutableListOfTff7b72<Te6edf3CallTff7b72>Tb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3callsTb4b4b4: Te6edf3ListTff7b72<Te6edf3CallTff7b72>
Tff7b72getTb4b4b4(Tb4b4b4) Tff7b72= Te6edf3_callsTb4b4b4.Te6edf3toListTb4b4b4(Tb4b4b4)

Tff7b72val Te6edf3callCountTb4b4b4: Tffa657Int
Tff7b72getTb4b4b4(Tb4b4b4) Tff7b72= Te6edf3_callsTb4b4b4.Te6edf3size

T8b949e/**
* Per-call [Flow] suppliers, indexed by call order. Call 0 returns [responses][0], call 1 returns [responses][1],
* etc. Calls beyond the list size return a never-completing flow (suspends until cancelled).
*/
Tff7b72val Te6edf3responses Tff7b72= Te6edf3mutableListOfTff7b72<Tb4b4b4(Tb4b4b4) Tff7b72-Tff7b72> Te6edf3FlowTff7b72<Te6edf3BleDeviceTff7b72>Tff7b72>Tb4b4b4(Tb4b4b4)

Tff7b72override Tff7b72fun Td2a8ffscanTb4b4b4(Te6edf3timeoutTb4b4b4: Te6edf3DurationTb4b4b4, Te6edf3serviceUuidTb4b4b4: Te6edf3kotlinTb4b4b4.Te6edf3uuidTb4b4b4.Te6edf3UuidTff7b72?Tb4b4b4, Te6edf3addressTb4b4b4: Tffa657String?Tb4b4b4)Tb4b4b4: Te6edf3FlowTff7b72<Te6edf3BleDeviceTff7b72> Tb4b4b4{
Tff7b72val Te6edf3index Tff7b72= Te6edf3_callsTb4b4b4.Te6edf3size
Te6edf3_calls Tff7b72+Tff7b72= Te6edf3CallTb4b4b4(Te6edf3timeoutTb4b4b4, Te6edf3serviceUuidTb4b4b4, Te6edf3addressTb4b4b4)
Tff7b72return Tff7b72if Tb4b4b4(Te6edf3index Tff7b72< Te6edf3responsesTb4b4b4.Te6edf3sizeTb4b4b4) Te6edf3responsesTff7b72[Te6edf3indexTff7b72]Tb4b4b4(Tb4b4b4) Tff7b72else Te6edf3flow Tb4b4b4{ Te6edf3awaitCancellationTb4b4b4(Tb4b4b4) Tb4b4b4}
Tb4b4b4}
Tb4b4b4}

Served by rngit 1.5.0 - Generated in 0.23s